嘘~ 正在从服务器偷取页面 . . .

Java 八股文


1. 基础概念

1.1 JVM、JDK、JRE

Java 虚拟机(Java Virtual Machine)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在

JDK(Java Development Kit)缩写,它是功能齐全的 Java SDK。它拥有 JRE 所拥有的一切,还有编译器(javac)和工具(如 javadoc 和 jdb)。它能够创建和编译程序。

JRE(Java Runtime Environment)是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,java 命令和其他的一些基础构件。但是,它不能用于创建新程序。

关系示例图

1.2 字节码

Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。

Java 编译过程

1.3 Java 语言“编译与解释并存”

Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码(.class 文件),这种字节码必须由 Java 解释器来解释执行。后面引进了 JIT(just-in-time compilation) 编译器,当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。

1.4 Java 编码格式

为了显示其他语言,需要统一的 Unicode 编码,但是这会导致内存浪费(只需要一个字节的英文字符却占用四个字节)。

默认使用 UTF-8 优化内存占用,特点是对不同范围的字符使用不同长度的编码。

UTF-8 是变长字节表示,遵循一个转换的原则:

① 对于单字节的符号,字节的第一位设为0,后面的7位为这个符号的 Unicode 码,因此对于英文字母,UTF-8 编码和 ASCII 码是相同的;

② 对于 n 字节的符号(n>1),第一个字节的前 n 位都设为1,第 n+1 位设为0,后面字节的前两位一律设为10,剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。

而 JVM 内部采用 UTF-16(出现时间早于 UTF-8),所以 Java 中的 char 是两字节的占用。

2. Java 基础

2.1 面向对象三大特性

封装性,继承性,多态性。

封装:是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性,就是 Java 对象中 Setter 和 Getter。

继承:是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。

多态:表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。

  • 对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
  • 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
  • 多态不能调用“只在子类存在但在父类不存在”的方法;
  • 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。

2.2 接口和抽象类

共同点:

  • 都不能被实例化;
  • 都可以包含抽象方法(在接口中,方法会默认修饰public abstract);
  • 都可以有默认实现的方法(Java 8 可以用 default 关键在接口中定义默认方法);

区别:

  • 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系;
  • 一个类只能继承一个类,但是可以实现多个接口;
  • 接口中的成员变量只能是public static final,不能被修改且必须有初始值,而抽象类的成员变量默认default,可在子类中被重新定义,也可被重新赋值。

2.3 深拷贝和浅拷贝

图示区别

  • 浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象;
  • 深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象;
  • 引用拷贝:引用指向同一个对象。

浅拷贝:

public class Address implements Cloneable{
    private String name;
    // 省略构造函数、Getter&Setter方法
    @Override
    public Address clone() {
        try {
            return (Address) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

public class Person implements Cloneable {
    private Address address;
    // 省略构造函数、Getter&Setter方法
    @Override
    public Person clone() {
        try {
            Person person = (Person) super.clone();
            return person;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}


// test
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// true
System.out.println(person1.getAddress() == person1Copy.getAddress());

深拷贝:

@Override
public Person clone() {
    try {
        Person person = (Person) super.clone();
        person.setAddress(person.getAddress().clone());
        return person;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

// test
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// false
System.out.println(person1.getAddress() == person1Copy.getAddress());

2.4 String

2.4.1 String 不可变

String 字符串常量,在 Java 中设定不可变,源码中定义为 final。

  1. 提升使用效率:

    如果大量的使用 String 常量,每一次声明一个 String 都创建一个 String 对象,将造成极大的空间浪费。Java 提出 String pool 的概念,在堆中开辟一块存储空间 String pool,当初始化一个 String 变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用,也可以提高 GC 的效率。

    由于 String 是不可变的,保证了 hashcode 的唯一性,于是在创建对象时其 hashcode 就缓存了,不需要重新计算。Map 将 String 作为 Key,处理速度要快过其它的键对象,所以键往往都使用 String。

  2. 避免值修改问题:

    如果采取可变的引用方式,对于一个值的修改会牵连到其他值,会导致安全问题:

public class test {
  // 不可变的 String
  public static String appendStr(String s) {
      s += "bbb";
      return s;
  }

  // 可变的 StringBuilder
  public static StringBuilder appendSb(StringBuilder sb) {
      return sb.append("bbb");
  }
  
  public static void main(String[] args) {
      String s = new String("aaa");
      String ns = test.appendStr(s);
      // s 的值会保持不变
      System.out.println("String aaa>>>" + s);
      // StringBuilder 做参数
      StringBuilder sb = new StringBuilder("aaa");
      StringBuilder nsb = test.appendSb(sb);
      System.out.println("StringBuilder aaa >>>" + sb.toString());
  }
}
  1. 多线程问题:

    在多线程情况中,读取不会有安全性问题。但是在写入的情况下,就可能会出现各种竞争的问题。

  2. 安全性问题:

    在网络连接和数据库连接中字符串常常作为参数,例如,网络连接地址 URL,文件路径 path,反射机制所需要的 String 参数。其不可变性可以保证连接的安全性。如果字符串是可变的,黑客就有可能改变字符串指向对象的值。因为String是不可变的,所以它的值是不可改变的。但由于String不可变,也就没有任何方式能修改字符串的值。、

2.4.2 StringBuilder & StringBuffer

StringBuffer 是线程安全的 StringBuilder 是不安全的。

StringBuilder 不支持并发操作,线性不安全的,不适合多线程中使用。新引入的 StringBuilder 类不是线程安全的,但其在单线程中的性能比 StringBuffer高。

2.5 泛型

通用的泛型标准:

  • E-Element 在集合中使用,表示在集合中存放的元素;
  • T-Type 表示Java类;
  • K-Key 表示键;
  • V-Value 表示值;
  • N-Number 表示数值类型;
  • ? 表示不确定的类型。

**对泛型的上限限定:<? extends T>**,表示该通配符所代表的类型是T的子类或是T的实现类;

**对泛型的下限限定:<? super T>**,表示该通配符所代表的类型是T的父类或父接口。

但是 Java 的泛型并不是真正的泛型,在编译时,泛型中的类型会被擦除。

public static void main(String[] args) {
    ArrayList<String> arrayString=new ArrayList<String>();
    ArrayList<Integer> arrayInteger=new ArrayList<Integer>();
    // output true
    System.out.println(arrayString.getClass()==arrayInteger.getClass());
}

2.6 Java 位运算

在 Java 中,二进制表示的数值的最高位是符号位。

>>:表示有符号右移,会考虑到符号位的情况;

>>>:表示无符号左移,会使用0补全最高位。

public class Test {
    public static void main(String[] args) {
        int a = 20;
        int b = -20;
        System.out.println(a >> 2);
        System.out.println(b >> 2);
        System.out.println(a >>> 2);
        System.out.println(b >>> 2);
        /*  
            5 
            -5
            5
            1073741819
        */
    }
}

2.7 static & final

static:

  1. 为某种特定数据类型或对象分配与创建对象个数无关的单一的存储空间;
  2. 使得某个方法或属性与类而不是对象关联在一起,即在不创建对象的情况下可通过类直接调用方法或使用类的属性。

final:

用于声明属性、方法和类,分别表示属性不可变、方法不可覆盖、类不可继承。

Java 代码执行顺序:

  1. 父类静态代码块(只执行一次);
  2. 子类静态代码块(只执行一次);
  3. 父类构造代码块;
  4. 父类构造函数;
  5. 子类构造代码块;
  6. 子类构造函数;
  7. 普通代码块。

3. Java 进阶

3.1 Java 代理模式

需要进行代理的接口以及实现:

// 需要代理的接口
public interface SmsService {
    void send(String message);
}

public class SmsServiceImpl implements SmsService {
    @Override
    public void send(String message) {
        System.out.println("send message:" + message);
    }
}

3.1.1 静态代理

静态代理中,我们对目标对象的每个方法的增强都是手动完成的,接口一旦新增加方法,目标对象和代理对象都要进行修改且需要对每个目标类都单独写一个代理类。

// 静态代理类的写法
public class SmsProxy implements SmsService {
    private final SmsService smsService;

    public SmsProxy(SmsService smsService) {
        this.smsService = smsService;
    }

    @Override
    public void send(String message) {
        // 调用方法之前,我们可以添加自己的操作
        System.out.println("before method send()");
        smsService.send(message);
        // 调用方法之后,我们同样可以添加自己的操作
        System.out.println("after method send()");
    }
}

// 主类直接调用代理类
public class Main {
    public static void main(String[] args) {
        SmsService smsService = new SmsServiceImpl();
        SmsProxy smsProxy = new SmsProxy(smsService);
        smsProxy.send("java");
    }
}

3.1.2 动态代理

JDK 代理

Spring AOP、RPC 框架应该是两个不得不提的,它们的实现都依赖了动态代理。

  • 创建被代理的目标类以及其实现的接口;
  • 创建 InvocationHandler 接口的实现类,在 invoke() 中完成要代理的功能;
  • Proxy.newInstance() 动态地构造出代理对象。
// 实现处理类,处理 proxy 的方法实现
public class DebugInvocationHandler implements InvocationHandler {
    private final Object target;

    public DebugInvocationHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy,
                         Method method,
                         Object[] args) throws Throwable {
        System.out.println("before");
        Object result = method.invoke(target, args);
        System.out.println("after");
        return result;
    }
}

// 封装一个代理工厂类
public class JdkProxyFactory {
    public static Object getProxy(Object target) {
        return Proxy.newProxyInstance(
                target.getClass().getClassLoader(), // 目标类的类加载
                target.getClass().getInterfaces(),  // 代理需要实现的接口,可指定多个
                new DebugInvocationHandler(target)   // 代理对象对应的自定义 InvocationHandler
        );
    }
}

public class Main {
    public static void main(String[] args) {
        SmsService smsService = (SmsService) JdkProxyFactory.getProxy(new SmsServiceImpl());
        smsService.send("java");
    }
}
CGLIB 代理

CGLIBopen in new window(Code Generation Library) 实际是属于一个开源项目,如果你要使用它的话,需要手动添加相关依赖。

<dependency>
    <groupId>cglib</groupId>
    <artifactId>cglib</artifactId>
    <version>3.3.0</version>
</dependency>
  1. JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。 另外, CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法;
  2. 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。

3.2 Java 集合

3.2.1 Java List

ArrayList & Vector
  • ArrayListList 的主要实现类,底层使用 Object[ ]存储,适用于频繁的查找工作,线程不安全 ;
  • VectorList 的古老实现类,底层使用Object[ ] 存储,线程安全的。

Arraylist 底层使用的是 Object 数组LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环),两者都不是线程安全的。

建议项目中不要使用 LinkedList,下面是 LinkedList 作者的推特内容。

LinkedList 开发者在 twitter 上的回答

ArrayList 扩容机制
public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    }
}

/**
     * Constructs an empty list with an initial capacity of ten.
     * ArrayList():并不是在初始化ArrayList的时候就设置初始容量为空,而是在添加第一个元素时,将初始容量设置为10(DEFAULT_CAPACITY)。
     * 在jdk1.6中,无参构造方法的初始容量为10
     */
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

public ArrayList(Collection<? extends E> c) {
    elementData = c.toArray();
    if ((size = elementData.length) != 0) {
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    } else {
        // replace with empty array.
        this.elementData = EMPTY_ELEMENTDATA;
    }
}

// 在源码中可以看到,每次需要扩容的时候,新数组都会扩展到原数组的1.5倍
private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

3.2.2 Java Set

冷知识:Java 的 Set 底层实现还是 Map。

HashSetLinkedHashSetTreeSet 的主要区别在于底层数据结构不同:

  • HashSet 的底层数据结构是哈希表(基于 HashMap 实现)。

  • LinkedHashSet 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO(先进先出原则)。

  • TreeSet 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。

在 openjdk8 中,实际上无论HashSet中是否已经存在了某元素,HashSet都会直接插入,只是会在add()方法的返回值处告诉我们插入前是否存在相同元素

3.2.3 Java Map

HashMap:

HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。

HashMap 总是使用2的幂作为哈希表的大小。因为哈希函数的计算方法为(n - 1) & hash,在这个前提下hash%length==hash&(length-1)。由于& 的运算效率大于%,这样对性能也有一定的提升。

JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间(使用红黑树避免出现二叉查找树的线性结构的问题)。

数据结构转换图示

对于负载因子的选择,Java 底层采取了0.75,涉及到数学中的泊松分布。

在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少扩容 rehash 操作次数,所以,一般在使用HashMap时建议根据预估值设置初始容量,以便减少扩容操作。

选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择。

在多线程情况下使用 HastMap 可能会出现循环链表的情况(在线程中一和线程二中,节点分别指向了之后的节点,形成一个闭环):

循环链表图示

对于 HashMap 的遍历问题,详情参考这篇文章的内容。结论来说,不能在遍历中使用集合 map.remove() 来删除数据,这是非安全的操作方式,但我们可以使用迭代器的 iterator.remove() 的方法来删除数据,这是安全的删除集合的方式。

从性能来说,在获取迭代器中EntrySetKeySet 的性能高。KeySet 在循环时使用了 map.get(key),而 map.get(key) 相当于又遍历了一遍 Map 集合去查询 key 所对应的值。在使用迭代器或者 for 循环时,已经遍历了一遍 Map 集合了,因此再使用 map.get(key) 查询时,相当于遍历了两遍

EntrySet 只遍历了一遍 Map 集合,之后通过代码Entry<Integer, String> entry = iterator.next()把对象的 keyvalue 值都放入到了 Entry 对象中,因此再获取 keyvalue 值时就无需再遍历 Map 集合。

EntrySet 的性能比 KeySet 的性能高出了一倍,因为 KeySet 相当于循环了两遍 Map 集合,而 EntrySet 只循环了一遍。

public static void entrySet() {
    Iterator var0 = map.entrySet().iterator();
    while(var0.hasNext()) {
        Entry var1 = (Entry)var0.next();
        System.out.println(var1.getKey());
        System.out.println((String)var1.getValue());
    }
}
public static void forEachEntrySet() {
    Iterator var0 = map.entrySet().iterator();
    while(var0.hasNext()) {
        Entry var1 = (Entry)var0.next();
        System.out.println(var1.getKey());
        System.out.println((String)var1.getValue());
    }
}
HashTable:
  • 不允许键值为 null;
  • put 方法使用 sychronized 方法进行线程同步。

单线程无需同步,多线程可用 concurrent 包的类型,Vector 也有类似的问题,所以两者都已经不建议使用。

ConcurrentHashMap:

在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。

Hashtable 全表锁

jdk1.7 ConcurrentHashMap

jdk1.8 ConcurrentHashMap
ConcurrentHashMap 取消了 Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全。数据结构跟 HashMap1.8 的结构类似,数组+链表/红黑二叉树。synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。

CAS 全称(Compare and Swap),即比较并替换。CAS本质上很简单,一般至少有3个参数:一个变量 v,旧值 A,新值 B。当且仅当变量 v 当前的值和旧值 A 相同时,才会将 v 的值更新为 B。

HashTree:

TreeMapHashMap 都继承自AbstractMap ,在这之外TreeMap实现了NavigableMap接口和SortedMap 接口。

实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。

实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力(默认是按 key 的升序排序)。

TreeMap 重写排序示例:

public class Person {
    private final Integer age;

    public Person(Integer age) {
        this.age = age;
    }

    public Integer getAge() {
        return age;
    }

    public static void main(String[] args) {
        // 通过 age 进行升序排序
        TreeMap<Person, String> treeMap = new TreeMap<>(Comparator.comparing(Person::getAge));
        treeMap.put(new Person(3), "person1");
        treeMap.put(new Person(18), "person2");
        treeMap.put(new Person(35), "person3");
        treeMap.put(new Person(16), "person4");
        treeMap.forEach((k, v) -> System.out.println(k.getAge() + ":" + v));
    }
}

3.2.4 Collection

Collection 是一个集合接口,它提供了对集合对象进行基本操作的通用接口方法,所有集合都是它的子类,比如 List、Set 等。

Collections 是一个包装类,包含了很多静态方法、不能被实例化,而是作为工具类使用,比如提供的排序方法:sort(list);提供的反转方法:reverse(list)。

可以类比 Array 和 Arrays。

3.3 Java 序列化

将存放在内存中的数据转移到硬盘中的过程,需要用到序列化(比如 ORM 框架)。

  • 序列化: 将数据结构或对象转换成二进制字节流的过程;
  • 反序列化:将在序列化过程中所生成的二进制字节流的过程转换成数据结构或者对象的过程。
  1. 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
  2. 将对象存储到文件中的时候需要进行序列化,将对象从文件中读取出来需要进行反序列化;
  3. 将对象存储到缓存数据库(如 Redis)时需要用到序列化,将对象从缓存数据库中读取出来需要反序列化。

Java 中的序列化需要实现 Serializable 接口,反序列化时需要提供无参的构造函数,不然会报错。

通过ObjectOutputStream&ObjectInputStream实现这一系列操作,分别调用writeObject(Object obj)&readObject()方法。

序列化过程示意图

3.3.1 序列化方式

原生 JDK 序列化方式存在性能差(序列化之后的字节数组体积较大,导致传输成本加大),不支持跨语言调用。

Kryo 是高性能的序列化/反序列化工具,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。在引入其对应依赖包后,就可以使用序列化。

3.3.2 注意点

  1. 静态成员变量不能序列化,序列化针对对象属性,而静态成员变量属于类;

  2. 在序列化时添加一个serialVersionUID。Java 会将传来的 UID 和本地的 UID 进行比对,如果不一致,不同意反序列化操作。

    导致 UID 兼容性问题可能有这些原因:

    • 手动修改导致当前的 serialVersionUID 与序列化前的不一样;
    • 缺少 serialVersionUID 常量,JVM 内部会根据类结构去计算 serialVersionUID,在类结构发生改变时(属性增加,删除或者类型修改了)导致 serialVersionUID 发生变化。
    • 假如类结构没有发生改变,并且没有定义 serialVersionUID ,虚拟机不一样也可能导致 serialVersionUID 不一样。

3.4 Java 反射

Java 反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及 动态调用对象的方法的功能称为 Java 语言的反射机制。

反射的缺点如下:

  • 性能瓶颈:反射相当于一系列解释操作,通知 JVM 要做的事情,性能比直接的 Java 代码要慢很多;

  • 安全问题:让我们可以动态操作改变类的属性同时也增加了类的安全隐患。

反射使用:

  • 我们在使用 JDBC 连接数据库时使用 Class.forName() 通过反射加载数据库的驱动程序;
  • Spring 框架的 IOC(动态加载管理 Bean)创建对象以及 AOP(动态代理)功能都和反射有联系;
  • 动态配置实例的属性。

4. Java 多线程

多线程大三特性:

  • 原子性:一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行;
  • 可见性:多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值;
  • 有序性:程序执行的顺序按照代码的先后顺序执行

4.1 线程死锁

死锁必须具备以下四个条件:

  1. 互斥条件:该资源任意一个时刻只由一个线程占用;
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放;
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源;
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

预防死锁:

  1. 破坏请求与保持条件 :一次性申请所有的资源;
  2. 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源;
  3. 破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

4.2 sleep() & wait()

sleep() 方法没有释放锁,而 wait() 方法释放了锁

  • 两者都可以暂停线程的执行;
  • wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行;
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。

4.3 为什么不直接调用 run() 方法

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。

直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它。

调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

4.4 synchronized

monitorentermonitorexit这两个 jvm 指令,主要是基于Mark WordObject monitor来实现的。

JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。锁可以升级不可降级,为了提高获得锁和释放锁的效率。

4.4.1 实现原理

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

jvm 对象内存分配

4.4.2 轻量锁

相对于使用操作系统互斥量来实现的传统锁而言,轻量级锁是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

加锁过程

(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word。

(2)拷贝对象头中的 Mark Word 复制到锁记录中。

(3)拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock record 里的 owner 指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。

(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2.2所示。

(5)如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

解锁过程

(1)通过 CAS 操作尝试把线程中复制的 Displaced Mark Word对象替换当前的Mark Word;

(2)如果替换成功,整个同步过程就完成了;

(3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

4.4.3 偏向锁

偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行,轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的 CAS 原子指令的性能消耗)。

获取锁的过程

(1)访问 Mark Word 中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态;

(2)如果为可偏向状态,则测试线程 ID 是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3);

(3)如果线程 ID 并未指向当前线程,则通过 CAS 操作竞争锁。如果竞争成功,则将 Mark Word 中线程 ID 设置为当前线程 ID,然后执行(5);如果竞争失败,执行(4);

(4)如果 CAS 获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码;

(5)执行同步代码。

解锁过程

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行)。

三者转换图示

4.4.4 synchronized & ReentrantLock

两者都是可重入锁。“可重入锁”指的是自己可以再次获取自己的内部锁。

比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。

synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API(需要配合 try catch & lock() unlock() 使用)。

ReentrantLock 提供了更安全的方法以及更多的功能。

4.5 volatile

在 JDK 1.5 中,java.util.concurrent(简称 JUC),增加了并发编程使用的工具类。

通过 AtomicBoolean 、AtomicInteger 、AtomicLong 和 AtomicReference 提供原子性支持。

5. JDK 特性

5.1 JDK 1.8

5.1.1 interface

新 interface 的方法可以用defaultstatic修饰,这样就可以有方法体,实现类也不必重写此方法。

  • default修饰的方法,是普通实例方法,可以用this调用,可以被子类继承、重写;

  • static修饰的方法,使用上和一般类静态方法一样。但它不能被子类继承,只能用Interface调用。

5.1.2 Lambda

可以使用方法引用来代替某些 Lambda 表达式

public class Test {
    public static void main(String[] args) {
        List<String> list = Arrays.asList("a", "b", "c", "d", "e");
        list.forEach(System.out::println);
//        list.forEach((String s) -> System.out.println(s));

        List<Integer> strings = Arrays.asList(1, 2, 3);

        /*strings.sort(new Comparator<Integer>() {
            @Override
            public int compare(Integer o1, Integer o2) {
                return o1 - o2;
            }
        });*/

        strings.sort(Comparator.comparingInt(o -> o));
    }
}

5.1.3 Stream

jdk 1.8 正式引入 stream 操作,通过时间换空间的方式,简化开发操作。stream 不用对原本的数据进行修改。

stream 操作,基本都是现用现查。

5.1.4 Optional

开发中,我们需要预防 NPE(java.lang.NullPointerException)问题。

(1) 返回类型为基本数据类型,return 包装数据类型的对象时,自动拆箱有可能产生 NPE;

(2) 数据库的查询结果可能为 null;

(3) 集合里的元素即使 isNotEmpty,取出的数据元素也可能为 null;

(4) 远程调用返回对象时,一律要求进行空指针判断,防止 NPE;

(5) 对于 Session 中获取的数据,建议进行 NPE 检查,避免空指针;

(6) 级联调用 obj.getA().getB().getC();一连串调用,易产生 NPE。

Zoo zoo = getZoo();
if(zoo != null){
   Dog dog = zoo.getDog();
   if(dog != null){
      int age = dog.getAge();
      System.out.println(age);
   }
}

// 对于冗长的连续空值判读,我们可以简化成以下代码块
// 这里使用方法引用,也可以使用 lambda 表达式
Optional.ofNullable(zoo).map(Zoo::getDog).map(Dog::getAge).ifPresent(System.out::println);

Optional 常用方法:

Optional.ofNullable(zoo).map(o -> o.getDog()).map(d -> d.getAge()).filter(v->v==1).orElse(3);

参考文章

  1. Java 学习+面试整理
  2. 轻松看懂 Java 字节码
  3. UTF-8 到底是什么意思
  4. 后端八股文整合
  5. 为什么这么多人不喜欢用 goto?
  6. Java 序列化与反序列化
  7. ArrayList 的扩容机制
  8. HashMap 的实现原理
  9. 为什么 HashMap 的加载因子是0.75?
  10. Java 中的 String 为什么要设计成不可变的?
  11. 为什么 HashTable 被弃用了
  12. HashTable 和 Vector 为什么逐渐被废弃
  13. Java HashMap的死循环
  14. HashMap? ConcurrentHashMap?
  15. Java 基础八股文背诵版
  16. ConcurrentHashMap,分段锁,CAS
  17. Java 动态代理
  18. Java 锁与线程的那些事
  19. Java 并发编程:Synchronized 底层优化(偏向锁、轻量级锁)

文章作者: 陈鑫扬
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 陈鑫扬 !
评论
  目录