1. Java内存模型

1.1 内存模型抽象结构

Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享局部变量、方法定义的参数和异常处理参数不会在线程间共享,它们不会有线程可见性问题,也不受内存模型影响。

Java内存模型(JMM):线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
image.png

1.2 重排序

重排序分为3种

  1. 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

1.3 内存屏障

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类,如表1-3所示。
image.png
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

1.4 happens-before原则

从JDK 5开始,Java使用新的JSR-133内存模型(除非特别说明,本文针对的都是JSR-133内存模型)。JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
与程序员密切相关的happens-before规则如下。

  • 程序顺序规则:一个线程中的每个操作,happens-before与该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before与随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。


注意:两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前

image.png
image.png

1.5 as-if-serial语义

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型,如表3-4所示。
image.png
上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。

as-if-serial

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

  1. double pi=3.14; //A
  2. double r=1.0; //B
  3. double area=pi*r*r; //C
  4. AB可能会重排序
  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。

    1.6 volatile

    特性

    volatile变量自身具有下列特性:

    1. 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
    2. 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。

内存语义

  1. 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
  2. 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

    内存语义的实现

    重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。表3-5是JMM针对编译器制定的volatile重排序规则表。
    image.png
  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。下面是基于保守策略的JMM内存屏障插入策略。

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

    1.7 单例模式双重检查锁使用volatile

    错误实现

    1. public class DoubleCheckedLocking { // 1
    2. private static Instance instance; // 2
    3. public static Instance getInstance() { // 3
    4. if (instance == null) { // 4:第一次检查
    5. synchronized (DoubleCheckedLocking.class) { // 5:加锁
    6. if (instance == null) // 6:第二次检查
    7. instance = new Instance(); // 7:问题的根源出在这里
    8. }
    9. }
    10. return instance;
    11. }
    12. }

    问题根源

    上面的双重检查锁定示例代码的第7行(instance=new Singleton();)创建了一个对象。这一行代码可以分解为如下的3行伪代码。 ```java memory = allocate();  // 1:分配对象的内存空间 ctorInstance(memory);  // 2:初始化对象 instance = memory;   // 3:设置instance指向刚分配的内存地址

2、3之间可能被重排序。

  1. 如果23之间发生了重排续,当对象只分配了内存空间和设置了指针,在第6步判断时指针指向地址不为空,这时就将未初始化的对象错误的返回了。<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/12504359/1647258803064-da1b4246-aebc-4259-8c67-79e69c71749f.png#clientId=u1fd448f7-3b9b-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=296&id=u3c95a2e8&margin=%5Bobject%20Object%5D&name=image.png&originHeight=592&originWidth=1832&originalType=binary&ratio=1&rotation=0&showTitle=false&size=306769&status=done&style=none&taskId=uf6462a96-587a-4d50-b281-c6b829df71c&title=&width=916)<br />有两个办法来实现线程安全的延迟初始化。
  2. - 不允许23之间的重排序
  3. - 允许23的重排序,但不允许其他线程“看到”这个重排序。
  4. <a name="l1T75"></a>
  5. ### 解决办法
  6. 1. 使用volatile修饰变量
  7. ```java
  8. public class DoubleCheckedLocking { // 1
  9. private volatile static Instance instance; // 2
  10. public static Instance getInstance() { // 3
  11. if (instance == null) { // 4:第一次检查
  12. synchronized (DoubleCheckedLocking.class) { // 5:加锁
  13. if (instance == null) // 6:第二次检查
  14. instance = new Instance(); // 7:问题的根源出在这里
  15. }
  16. }
  17. return instance;
  18. }
  19. }

当声明对象引用为volatile后,2、3之间的重排序,在多线程环境下将会被禁止。

  1. 基于类初始化方案

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

  1. public class InstanceFactory {
  2. private static class InstanceHolder {
  3. public static Instance instance = new Instance();
  4. }
  5. public static Instance getInstance() {
  6. return InstanceHolder.instance ;  // 这里将导致InstanceHolder类被初始化
  7. }
  8. }

2. synchronized

2.1 使用方式

  1. 修饰方法:对象锁

    1. synchronized void method() {
    2. //业务代码
    3. }
  2. 修饰静态方法:类锁

    1. synchronized static void method() {
    2. //业务代码
    3. }
  3. 修饰代码块:synchronized(this)对象锁,synchronized(Object.class)类锁(所有对象实例只有一把)

    1. synchronized() {
    2. //业务代码
    3. }

    2.2 原理

    2.2.1 synchronized修饰同步代码块

    使用对象监视器monitor实现,进入synchronized同步代码块时,monitorenter会指向同步代码块的开始位置,monitorexit指向同步代码块结束位置。执行monitorenter指令时,会尝试获取对象的锁,如果锁计数器为0表示可以获取锁,获取成功后锁计数器加1,一个对象已经获取锁后可以再次获取锁,执行monitorexit指令时,锁计数器减1,计数器为0时就释放锁了。

    2.2.2 synchronized修饰方法时

    synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

2.3 JDK6对synchronized的优化

https://www.cnblogs.com/wuqinglong/p/9945618.html

对象头

在HotSpot虚拟机中,对象在内存中的布局分3块区域:对象头、实例数据和对齐填充。对象头包含MarkWord和类型指针,如果是数组对象头中还有一部分存储的是数组的长度。
多线程下 synchronized 的加锁就是对同一个对象的对象头中的 MarkWord 中的变量进行CAS操作。

  1. MarkWord:存储对象自身的运行数据,如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。
  2. 类型指针:虚拟机通过这个指针确定对象是哪个实例。

    优化后synchronized锁的分类

    级别从低到高依次为:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。锁可以升级但不能降级,也是就是说锁升级的过程是不可逆的。

  3. 偏向锁

  4. 轻量级锁

    2.4 内存语义

  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

对比锁释放-获取的内存语义与volatile写-读的内存语义可以看出:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。

2.5 synchronized与ReentrantLock的区别

  1. synchronized是关键字,是JVM层面的底层啥都帮我们做了,ReentrantLock是类。
  2. 两者都是可重入锁。
  3. synchronized是非公平锁,ReentrantLock默认是非公平锁,也可以通过构造方法创建公平锁。
  4. ReentrantLock可以用Condition类实现精确唤醒锁,synchronized只能使用Object类的notify()和notifyAll()方法唤醒一个或者所有锁,使用notify()唤醒锁是由系统决定的。
  5. 一个线程获取到synchronized锁后,其他线程只能处于阻塞或者等待状态,不可以被中断。ReentrantLock可以被中断也可以不被中断。

    3. final域的内存语义

    对于final域,编译器和处理器要遵守两个重排序规则。

  6. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

  7. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

4.Thread类方法

Thread.join()

如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才从thread.join()返回。

5. 死锁

死锁产生的必要条件

  • 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程释放。
  • 请求与保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持占有。
  • 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  • 循环等待条件:指在发生死锁时,必然存在一个进程对应的环形链。

    6. 线程池

    6.1 TheadPoolExecutor

    6.1.1 7个参数

  1. int corePoolSize:核心线程数
  2. int maximumPoolSize:最大线程数
  3. long keepAliveTime:超过核心线程数的线程空闲时间
  4. TimeUnit unit:时间单位
  5. BlockingQueue workQueue:工作队列
  6. RejectExecutionHandler handler:拒绝策略

    6.1.2 拒绝策略

  7. ThreadPoolExecutor.AbortPolicy:抛出RejectExecutionException来拒绝新任务的处理。

  8. ThreadPoolExecutor.CallerRunsPolicy:调用者执行任务
  9. ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃
  10. ThreadPoolExecutor.DiscardOldestPolicy:丢弃最早未处理的任务

    6.1.3 原理

    当一个任务被提交到线程池后:

    1. 如果此时线程数小于核心线程数,那么就会新建一个线程来执行当前任务
    2. 如果此时线程大于核心线程数,那么就会将任务塞入阻塞队列中,等待被执行
    3. 如果阻塞队列满了,并且此时线程数小于最大线程数,那么会创建新线程来执行当前任务
    4. 如果阻塞队列满了,并且此时线程数大于最大线程数,那么会执行拒绝策略

      6.1.4 状态

      线程池几种状态
  • RUNNING:能接受新任务,并处理阻塞队列中的任务。
  • SHUTDOWN:不接受新任务,但是可以处理阻塞队列中的任务。
  • STOP:不接受新任务,并且不处理阻塞队列中的任务,并且还打断正在运行任务的线程。
  • TIDYING:所有任务都终止,并且工作线程也为0,处于关闭之前的状态。
  • TERMINATED:已关闭。

状态转换如图:
线程池状态转换

7. 锁

7.1 互斥锁

互斥锁是一种独占锁(悲观锁),比如当A线程加锁成功后,此时互斥锁已经被线程A独占了,只要A线程不释放锁,线程B的加锁就会失败,于是就会释放CPU,线程B将会阻塞。
synchronized就是一种独占锁,会导致其他需要锁的线程挂起,等待持有锁的线程释放锁。

7.2 自旋锁

自旋锁是通过CPU提供的CAS函数(compare and swap),在完成加锁和解锁的操作,不会主动产生线程的上下文切换,所以相比于互斥锁来说,会快一些,开销也小一些。
CAS原理:内存值V,旧的预期值A,要修改的新值B,当A=V时,将内存值修改为B,否则什么都不做。
缺点:
(1)会产生ABA问题。(加版本号)
(2)如果CAS加锁失败,会一直自旋,会给CPU带来压力。
(3)只能保证一个变量的原子性操作,i++这种是不能保证的。

7.3 乐观锁与悲观锁

悲观锁,认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要加锁。
乐观锁,认为冲突的概率很低,工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程修改资源,那么操作完成,如果发现其他线程已经修改过这个资源,放弃本次操作。