【原理篇】再次带你进入多线程的世界

1、Java内存模型基础知识

1.1并发编程模型的两个关键问题

  • 线程间如何通信?即:线程之间以何种机制来交换信息
  • 线程间如何同步?即:线程以何种机制来控制不同线程间操作发⽣的相对顺序

有两种并发模型可以解决这两个问题:

  • 消息传递并发模型
  • 共享内存并发模型

这两种模型之间的区别如下表所示:
请添加图片描述

在Java中,使⽤的是共享内存并发模型

1.2 Java内存模型的抽象结构

1.2.1 运⾏时内存的划分

先谈⼀下运⾏时数据区,下⾯这张图相信⼤家⼀点都不陌⽣:
请添加图片描述

对于每⼀个线程来说,栈都是私有的,⽽堆是共有的。

也就是说在栈中的变量(局部变量、⽅法定义参数、异常处理器参数)不会在线程 之间共享,也就不会有内存可⻅性的问题,也不受内存模型的影响。⽽在堆中的变量是共享的,称为共享变量。

所以,内存可⻅性是针对的共享变量

1.2.2 既然堆是共享的,为什么在堆中会有内存不可⻅问题?

这是因为现代计算机为了⾼效,往往会在⾼速缓存区中缓存共享变量,因为cpu访 问缓存区⽐访问内存要快得多。

线程之间的共享变量存在主内存中,每个线程都有⼀个私有的本地内存,存储了该线程以读、写共享变量的副本。本地内存是Java内存模型的⼀个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。

Java线程之间的通信由Java内存模型(简称JMM)控制,从抽象的⻆度来说, JMM定义了线程和主内存之间的抽象关系。JMM的抽象示意图如图所示:
请添加图片描述

从图中可以看出:

  1. 所有的共享变量都存在主内存中。

  2. 每个线程都保存了⼀份该线程使⽤到的共享变量的副本

  3. 如果线程A与线程B之间要通信的话,必须经历下⾯2个步骤:

    i. 线程A将本地内存A中更新过的共享变量刷新到主内存中去。

    ii. 线程B到主内存中去读取线程A之前已经更新过的共享变量。

所以,线程A⽆法直接访问线程B的⼯作内存,线程间通信必须经过主内存。

注意,根据JMM的规定,线程对共享变量的所有操作都必须在⾃⼰的本地内存中进⾏不能直接从主内存中读取

所以线程B并不是直接去主内存中读取共享变量的值,⽽是先在本地内存B中找到 这个共享变量,发现这个共享变量已经被更新了,然后本地内存B去主内存中读取 这个共享变量的新值,并拷⻉到本地内存B中,最后线程B再读取本地内存B中的新值。

那么怎么知道这个共享变量的被其他线程更新了呢?这就是JMM的功劳了,也是 JMM存在的必要性之⼀。JMM通过控制主内存与每个线程的本地内存之间的交 互,来提供内存可⻅性保证。

Java中的volatile关键字可以保证多线程操作共享变量的可⻅性以及禁⽌指令重排序,synchronized关键字不仅保证可⻅性,同时也保证了原⼦性(互斥性)。
在更底层,JMM通过内存屏障来实现内存的可⻅性以及禁⽌重排序。

为了程序员的⽅便理解,提出了happens-before,它更加的简单易懂,从⽽避免了程序员为了理解内存可⻅性⽽去学习复杂的重排序规则以及这些规则的具体实现⽅法。
1.2.3 JMM与Java内存区域划分的区别与联系

上⾯两⼩节分别提到了JMM和Java运⾏时内存区域的划分,这两者既有差别⼜有联系:

  • 区别

两者是不同的概念层次。JMM是抽象的,他是⽤来描述⼀组规则,通过这个规 则来控制各个变量的访问⽅式,围绕原⼦性、有序性、可⻅性等展开的。⽽ Java运⾏时内存的划分是具体的,是JVM运⾏Java程序时,必要的内存划分。

  • 联系

    都存在私有数据区域和共享数据区域。⼀般来说,JMM中的主内存属于共享数 据区域,他是包含了堆和⽅法区;同样,JMM中的本地内存属于私有数据区 域,包含了程序计数器、本地⽅法栈、虚拟机栈。

2、重排序与happens-before

2.1 什么是重排序?

计算机在执⾏程序时,为了提⾼性能,编译器和处理器常常会对指令做重排 。

为什么指令重排序可以提高性能?

简单地说,每⼀个指令都会包含多个步骤,每个步骤可能使⽤不同的硬件。因此, 流水线技术产⽣了,它的原理是指令1还没有执⾏完,就可以开始执⾏指令2,⽽不⽤等到指令1执⾏结束之后再执⾏指令2,这样就⼤⼤提⾼了效率。

但是,流⽔线技术最害怕中断,恢复中断的代价是⽐较⼤的,所以我们要想尽办法 不让流⽔线中断。指令重排就是减少中断的⼀种技术。

我们分析⼀下下⾯这个代码的执⾏情况:

a = b + c;
d = e - f ;

先加载b、c(注意,即有可能先加载b,也有可能先加载c),但是在执⾏add(b,c) 的时候,需要等待b、c装载结束才能继续执⾏,也就是增加了停顿,那么后⾯的指 令也会依次有停顿,这降低了计算机的执⾏效率。

为了减少这个停顿,我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串⾏)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做⼀些有 意义的事情。

综上所述,指令重排对于提⾼CPU处理性能⼗分必要。虽然由此带来了乱序的问 题,但是这点牺牲是值得的。

指令重排⼀般分为以下三种:

  • 编译器优化重排

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执⾏顺序。

  • 指令并⾏重排

现代处理器采⽤了指令级并⾏技术来将多条指令重叠执⾏。如果不存在数据依 赖性(即后⼀个执⾏的语句⽆需依赖前⾯执⾏的语句的结果),处理器可以改变 语句对应的机器指令的执⾏顺序。

  • 内存系统重排

由于处理器使⽤缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看 上去可能是在乱序执⾏,因为三级缓存的存在,导致内存与缓存的数据同步存 在时间差。

**指令重排可以保证串⾏语义⼀致,但是没有义务保证多线程间的语义也⼀致。**所以 在多线程下,指令重排序可能会导致⼀些问题。

2.2happens-before

2.2.1什么是happens-before?

JMM提供了happens-before规则(JSR-133规范),满⾜了程序 员的需求——简单易懂,并且提供了⾜够强的内存可⻅性保证。换⾔之,程序员只 要遵循happens-before规则,那他写的程序就能保证在JMM中具有强的内存可⻅性。

happens-before关系的定义如下:

  1. 如果⼀个操作happens-before另⼀个操作,那么第⼀个操作的执⾏结果将对第 ⼆个操作可⻅,⽽且第⼀个操作的执⾏顺序排在第⼆个操作之前。
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必 须要按照happens-before关系指定的顺序来执⾏。如果重排序之后的执⾏结 果,与按happens-before关系来执⾏的结果⼀致,那么JMM也允许这样的重 排序。

总之,如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作 B都是可⻅的,不管它们在不在⼀个线程。

2.2.2 天然的happens-before关系

在Java中,有以下天然的happens-before关系:

  • 程序顺序规则: 同一个线程中前面的所有写操作对后面的操作可见。
  • 监视器锁规则:对⼀个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对⼀个volatile的写,happens-before于任意后续对这个 volatile的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happensbefore C。
  • start规则: 同一个线程的 start() 方法 happen-before 于此线程的其它方法。
  • join规则:如果线程A执⾏操作ThreadB.join()并成功返回,那么线程B中的 任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  • 程序中断规则: 线程 A 写入的所有变量,调用 Thread.interrupt(),被打断的线程 B,可以看到 A 的全部操作。
  • 线程终结原则:线程中的所有操作都 happen-before 线程的终止检测。
  • 对象创建原则:一个对象的初始化完成(构造函数执行结束)先于他的 finalize() 方法调用。

举例:

int a = 1; // A操作
int b = 2; // B操作
int sum = a + b;// C 操作
System.out.println(sum);
1> A happens-before B
2> B happens-before C
3> A happens-before C

注意,真正在执⾏指令的时候,其实JVM有可能对操作A & B进⾏重排序,因为⽆ 论先执⾏A还是B,他们都对对⽅是可⻅的,并且不影响执⾏结果。

如果这⾥发⽣了重排序,这在视觉上违背了happens-before原则,但是JMM是允许 这样的重排序的。

所以,我们只关⼼happens-before规则,不⽤关⼼JVM到底是怎样执⾏的。只要确 定操作A happens-before操作B就⾏了。

重排序有两类,JMM对这两类重排序有不同的策略:

  • 会改变程序执⾏结果的重排序,⽐如 A -> C,JMM要求编译器和处理器都禁⽌ 这种重排序。
  • 不会改变程序执⾏结果的重排序,⽐如 A -> B,JMM对编译器和处理器不做要 求,允许这种重排序。

3、volatile

3.1 ⼏个基本概念

3.1.1 内存可⻅性

在Java内存模型那⼀章我们介绍了JMM有⼀个主内存,每个线程有⾃⼰私有的⼯作 内存,⼯作内存中保存了⼀些变量在主内存的拷⻉。

内存可⻅性,指的是线程之间的可⻅性,当⼀个线程修改了共享变量时,另⼀个线 程可以读取到这个修改后的值。

3.1.2 重排序

为优化程序性能,对原有的指令执⾏顺序进⾏优化重新排序。重排序可能发⽣在多 个阶段,⽐如编译重排序、CPU重排序等。

3.1.3 happens-before规则

是⼀个给程序员使⽤的规则,只要程序员在写代码的时候遵循happens-before规 则,JVM就能保证指令在多线程之间的顺序性符合程序员的预期。

3.2 volatile的内存语义

在Java中,volatile关键字有特殊的内存语义。volatile主要有以下两个功能:

  • 保证变量的内存可⻅性
  • 禁⽌volatile变量与普通变量重排序(JSR133提出,Java 5 开始才有这个“增强 的volatile内存语义”)
3.2.1 内存可⻅性

案例:

public class VolatileTest {
    int a=0;
    volatile  boolean flag=false;
    public void write(){
        a=1; //step1
        flag=true;  //step2
    }

    public void read(){
        if (flag){ //step3
            System.out.println(a);//step4
        }
    }
}

在这段代码⾥,我们使⽤ volatile 关键字修饰了⼀个 boolean 类型的变量 flag 。

所谓内存可⻅性,指的是当⼀个线程对 volatile 修饰的变量进⾏写操作(⽐如 step 2)时,JMM会⽴即把该线程对应的本地内存中的共享变量的值刷新到主内 存;当⼀个线程对 volatile 修饰的变量进⾏读操作(⽐如step 3)时,JMM会把 ⽴即该线程对应的本地内存置为⽆效,从主内存中读取共享变量的值。 `

假设在时间线上,线程A先执行⽅法 writer ⽅法,线程B后执⾏ reader ⽅法。那 必然会有下图:

请添加图片描述

⽽如果 flag 变量没有⽤ volatile 修饰,在step 2,线程A的本地内存⾥⾯的变量 就不会⽴即更新到主内存,那随后线程B也同样不会去主内存拿最新的值,仍然使 ⽤线程B本地内存缓存的变量的值 a = 0,flag = false 。

3.2.1 禁⽌重排序

在JSR-133之前的旧的Java内存模型中,是允许volatile变量与普通变量重排序的。

那上⾯的案例中,可能就会被重排序成下列时序来执⾏:

  1. 线程A写volatile变量,step 2设置flag为true;
  2. 线程B读同⼀个volatile,step 3,读取到flag为true;
  3. 线程B读普通变量,step 4,读取到 a = 0;
  4. 线程A修改普通变量,step 1,设置 a = 1;

可⻅,如果volatile变量与普通变量发⽣了重排序,虽然volatile变量能保证内存可⻅ 性,也可能导致普通变量读取错误。

JVM是怎么还能限制处理器的重排序的呢?它是通过内存屏障来实 现的。

什么是内存屏障?硬件层⾯,内存屏障分两种:读屏障(Load Barrier)和写屏障 (Store Barrier)。

内存屏障有两个作⽤

  1. 阻⽌屏障两侧的指令重排序;
  2. 强制把写缓冲区/⾼速缓存中的脏数据等写回主内存,或者让缓存中相应的数据 失效。

JMM内存屏障插⼊策略

  • 在每个volatile写操作前插⼊⼀个StoreStore屏障;
  • 在每个volatile写操作后插⼊⼀个StoreLoad屏障;
  • 在每个volatile读操作后插⼊⼀个LoadLoad屏障;
  • 在每个volatile读操作后再插⼊⼀个LoadStore屏障。

图解:

请添加图片描述

请添加图片描述

对于连续多个volatile变量读或者连续多个volatile变量写,编译器做了⼀定的优化来 提⾼性能,⽐如:

第⼀个volatile;
LoadLoad屏障;
第⼆个volatile读;
LoadStore屏障

再介绍⼀下volatile与普通变量的重排序规则:

  1. 如果第⼀个操作是volatile读,那⽆论第⼆个操作是什么,都不能重排序;
  2. 如果第⼆个操作是volatile写,那⽆论第⼀个操作是什么,都不能重排序;
  3. 如果第⼀个操作是volatile写,第⼆个操作是volatile读,那不能重排序。

举个例⼦,我们在案例中step 1,是普通变量的写,step 2是volatile变量的写,那 符合第2个规则,这两个steps不能重排序。⽽step 3是volatile变量读,step 4是普 通变量读,符合第1个规则,同样不能重排序。

但如果是下列情况:第⼀个操作是普通变量读,第⼆个操作是volatile变量读,那是 可以重排序的:

// 声明变量
int a = 0; // 声明普通变量
volatile boolean flag = false; // 声明volatile变量

// 以下两个变量的读操作是可以重排序的
int i = a; // 普通变量读
boolean j = flag; // volatile变量读

3.3volatile的⽤途

从volatile的内存语义上来看,volatile可以保证内存可⻅性且禁⽌重排序。

在保证内存可⻅性这⼀点上,volatile有着与锁相同的内存语义,所以可以作为⼀个 **“轻量级”**的锁来使⽤。但由于volatile仅仅保证对单个volatile变量的读/写具有原⼦ 性,⽽锁可以保证整个临界区代码的执⾏具有原⼦性。所以在功能上,锁⽐ volatile更强⼤;在性能上,volatile更有优势。

在禁⽌重排序这⼀点上,volatile也是⾮常有⽤的。⽐如我们熟悉的单例模式,其中 有⼀种实现⽅式是“双重锁检查”,⽐如这样的代码:

public class Singleton {
     private static Singleton instance; // 不使⽤volatile关键字
     // 双重锁检验
     public static Singleton getInstance() {
         if (instance == null) { // 第7⾏
        	 synchronized (Singleton.class) {
         		if (instance == null) {
         			instance = new Singleton(); // 第10⾏
     			}
     		}
   	 	}
     	return instance;
    }
}

 /**
 *这是否安全? instance = new Singleton(); 并不是原子性操作
 * jvm中 instance实例化内存模型流程如下:
 * 1.分配空间给对象
 * 2.在空间内创建对象
 * 3.将对象赋值给instance引用
 *
 * 假如出现如下顺序错乱的情况:
 * 线程的执行顺序为:1 -> 3 -> 2, 那么这时候会把值写回主内存
 * 则,其他线程就会读取到instance的最新值,但是这个是不完全的对象
 * (指令重排现象)
*/

所以JSR-133对volatile做了增强后,volatile的禁⽌重排序功能还是⾮常有⽤的。

4、 synchronized与锁

这篇⽂章我们来聊⼀聊Java多线程⾥⾯的“锁”。

⾸先需要明确的⼀点是:Java多线程的锁都是基于对象的,Java中的每⼀个对象 都可以作为⼀个锁。

还有⼀点需要注意的是,我们常听到的类锁其实也是对象锁。

Java类只有⼀个Class对象(可以有多个实例对象,多个实例共享这个Class对 象),⽽Class对象也是特殊的Java对象。所以我们常说的类锁,其实就是Class对象的锁

4.1 Synchronized关键字

说到锁,我们通常会谈到 synchronized 这个关键字。它翻译成中⽂就是“同步”的意 思。

我们通常使⽤ synchronized 关键字来给⼀段代码或⼀个⽅法上锁。它通常有以下 三种形式:

// 关键字在实例⽅法上,锁为当前实例
public synchronized void instanceLock() {
 	// code
}

// 关键字在静态⽅法上,锁为当前Class对象
public static synchronized void classLock() {
 	// code
}

// 关键字在代码块上,锁为括号⾥⾯的对象
public void blockLock() {
     Object o = new Object();
     synchronized (o) {
     	// code
     }
}

我们这⾥介绍⼀下“临界区”的概念。所谓“临界区”,指的是某⼀块代码区域,它同 ⼀时刻只能由⼀个线程执⾏。在上⾯的例⼦中,如果 synchronized 关键字在⽅法 上,那临界区就是整个⽅法内部。⽽如果是使⽤synchronized代码块,那临界区就 指的是代码块内部的区域。

通过上⾯的例⼦我们可以看到,下⾯这两个写法其实是等价的作⽤:

// 关键字在实例⽅法上,锁为当前实例
public synchronized void instanceLock() {
	 // code
}

// 关键字在代码块上,锁为括号⾥⾯的对象
public void blockLock() {
     synchronized (this) {
    	 // code
     }
}

同理,下⾯这两个⽅法也应该是等价的:

// 关键字在静态⽅法上,锁为当前Class对象
public static synchronized void classLock() {
 	// code
}

// 关键字在代码块上,锁为括号⾥⾯的对象
public void blockLock() {
     synchronized (this.getClass()) {
    	 // code
     }
}

4.2 ⼏种锁

Java 6 为了减少获得锁和释放锁带来的性能消耗,引⼊了“偏向锁”和“轻量级锁“。 在Java 6 以前,所有的锁都是”重量级“锁。所以在Java 6 及其以后,⼀个对象其实 有四种锁状态,它们级别由低到⾼依次是:

  1. ⽆锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

⼏种锁会随着竞争情况逐渐升级,锁的升级很容易发⽣,但是锁降级发⽣的条件会 ⽐较苛刻,锁降级发⽣在Stop The World期间,当JVM进⼊安全点的时候,会检查 是否有闲置的锁,然后进⾏降级。

关于锁降级有两点说明:
1.不同于⼤部分⽂章说锁不能降级,实际上HotSpot JVM 是⽀持锁降级的。
2.上⾯提到的Stop The World期间,以及安全点,这些知识是属于JVM的知识范畴,本⽂不做细讲,后面会出相关知识。

下⾯分别介绍这⼏种锁以及它们之间的升级。

4.2.1 Java对象头

前⾯我们提到,Java的锁都是基于对象的。⾸先我们来看看⼀个对象的“锁”的信息 是存放在什么地⽅的。

每个Java对象都有对象头。如果是⾮数组类型,则⽤2个字宽来存储对象头,如果 是数组,则会⽤3个字宽来存储对象头。在32位处理器中,⼀个字宽是32位;在64 位虚拟机中,⼀个字宽是64位。对象头的内容如下表:
请添加图片描述

看Mark Word的格式 :

请添加图片描述

可以看到,当对象状态为偏向锁时, Mark Word 存储的是偏向的线程ID;当状态为 轻量级锁时, Mark Word 存储的是指向线程栈中 Lock Record 的指针;当状态为重 量级锁时, Mark Word 为指向堆中的monitor对象的指针。

4.2.2 偏向锁

⼤多数情况下锁不仅不存在多线程竞争,**⽽且总是由同⼀线程多次获得,**于是引⼊了偏向锁。

偏向锁会偏向于第⼀个访问锁的线程,如果在接下来的运⾏过程中,该锁没有被其 他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在 资源⽆竞争情况下消除了同步语句,连CAS操作都不做了,提⾼了程序的运⾏性 能。

实现原理 :

⼀个线程在第⼀次进⼊同步块时,会在对象头和栈帧中的锁记录⾥存储锁的偏向的 线程ID。当下次该线程进⼊这个同步块时,会去检查锁的Mark Word⾥⾯是不是放 的⾃⼰的线程ID。

如果是,表明该线程已经获得了锁,以后该线程在进⼊和退出同步块时不需要花费 CAS操作来加锁和解锁 ;

如果不是,就代表有另⼀个线程来竞争这个偏向锁。这 个时候会尝试使⽤CAS来替换Mark Word⾥⾯的线程ID为新线程的ID,这个时候要 分两种情况:

  • 成功,表示之前的线程不存在了, Mark Word⾥⾯的线程ID为新线程的ID,锁 不会升级,仍然为偏向锁;
  • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为 0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的⽅式进⾏竞争 锁。
CAS: Compare and Swap ⽐较并设置。⽤于在硬件层⾯上提供原⼦性操作。在 Intel 处理器中,⽐较并交换通过指令cmpxchg实现。 ⽐较是否和给定的数值⼀致,如果⼀致则修 改,不⼀致则不修改。  

线程竞争偏向锁的过程如下 :

请添加图片描述

图中涉及到了lock record指针指向当前堆栈中的最近⼀个lock record,是轻量级锁 按照先来先服务的模式进⾏了轻量级锁的加锁。

撤销偏向锁

偏向锁使⽤了⼀种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁 时, 持有偏向锁的线程才会释放锁

偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程 看起来容易,实则开销还是很⼤的,⼤概的过程如下:

  1. 在⼀个安全点(在这个时间点上没有字节码正在执⾏)停⽌拥有锁的线程。
  2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成 ⽆锁状态。
  3. 唤醒被停⽌的线程,将当前锁升级成轻量级锁。

所以,如果应⽤程序⾥所有的锁通常出于竞争状态,那么偏向锁就会是⼀种累赘, 对于这种情况,我们可以⼀开始就把偏向锁这个默认功能给关闭:

-XX:UseBiasedLocking=false

下⾯这个经典的图总结了偏向锁的获得和撤销:

请添加图片描述

4.2.3 轻量级锁

多个线程在不同时段获取同⼀把锁,即不存在锁竞争的情况,也就没有线程阻塞。 针对这种情况,JVM采⽤轻量级锁来避免线程的阻塞与唤醒。

轻量级锁的加锁

JVM会为每个线程在当前线程的栈帧中创建⽤于存储锁记录的空间,我们称为 Displaced Mark Word。如果⼀个线程获得锁的时候发现是轻量级锁,会把锁的 Mark Word复制到⾃⼰的Displaced Mark Word⾥⾯。

然后线程尝试⽤CAS将锁的Mark Word替换为指向锁记录的指针。如果成功,当前 线程获得锁,如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明 在与其它线程竞争锁,当前线程就尝试使⽤⾃旋来获取锁。

⾃旋:不断尝试去获取锁,⼀般⽤循环来实现。

⾃旋是需要消耗CPU的,如果⼀直获取不到锁的话,那该线程就⼀直处在⾃旋状 态,⽩⽩浪费CPU资源。解决这个问题最简单的办法就是指定⾃旋的次数,例如让 其循环10次,如果还没获取到锁就进⼊阻塞状态。

但是JDK采⽤了更聪明的⽅式——适应性⾃旋,简单来说就是线程如果⾃旋成功 了,则下次⾃旋的次数会更多,如果⾃旋失败了,则⾃旋的次数就会减少。

⾃旋也不是⼀直进⾏下去的,如果⾃旋到⼀定程度(和JVM、操作系统相关),依 然没有获取到锁,称为⾃旋失败,那么这个线程会阻塞。同时这个锁就会升级成重 量级锁。

轻量级锁的释放

在释放锁时,当前线程会使⽤CAS操作将Displaced Mark Word的内容复制回锁的 Mark Word⾥⾯。如果没有发⽣竞争,那么这个复制的操作会成功。如果有其他线 程因为⾃旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释 放锁并唤醒被阻塞的线程。

⼀张图说明加锁和释放锁的过程

请添加图片描述

4.2.4 重量级锁

其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程,进行竞争。

重量级锁在JVM中有一个监视器(Monitor),保持了两个队列:锁竞争队列和信号阻塞队列,一个实现线程互斥,另一个实现线程同步。重量级锁在底层是靠操作系统的Mutex Lock实现的,线程在阻塞和唤醒状态间切换需要操作系统将线程在用户态与核心态之间转换,成本很高,所以最早的synchronized效率不高。

4.2.5偏向锁、轻量级锁和重量级锁对比

请添加图片描述

通俗来讲就是:

  • 偏向锁:仅有一个线程进入临界区
  • 轻量级锁:多个线程交替进入临界区
  • 重量级锁:多个线程同时进入临界区
4.2.6 乐观锁和悲观锁

锁可以从不同的⻆度分类。其中,乐观锁和悲观锁是⼀种分类⽅式。

悲观锁

悲观锁就是我们常说的锁。对于悲观锁来说,它总是认为每次访问共享资源时会发 ⽣冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同⼀时间只能有⼀ 个线程在执⾏。

乐观锁:

乐观锁⼜称为“⽆锁”,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问 没有冲突,线程可以不停地执⾏,⽆需加锁也⽆需等待。⽽⼀旦多个线程发⽣冲 突,乐观锁通常是使⽤⼀种称为CAS的技术来保证线程执⾏的安全性。 由于⽆锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说乐观锁天⽣免疫死锁

乐观锁多⽤于“读多写少“的环境,避免频繁加锁影响性能;⽽悲观锁多⽤于”写多读 少“的环境,避免频繁失败和重试影响性能。

5、ThreadLocal

5.1 ThreadLocal 简介

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢? JDK 中提供的ThreadLocal类正是为了解决这样的问题。 ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。

如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get()set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

再举个简单的例子:

比如有两个人去宝屋收集宝物,这两个共用一个袋子的话肯定会产生争执,但是给他们两个人每个人分配一个袋子的话就不会出现这样的问题。如果把这两个人比作线程的话,那么 ThreadLocal 就是用来避免这两个线程竞争的 

5.2 ThreadLocal 原理

ThreadLocal 原理

public class Thread implements Runnable {
    //......
    //与此线程有关的ThreadLocal值。由ThreadLocal类维护
    ThreadLocal.ThreadLocalMap threadLocals = null;

    //与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
    //......
}

从上面Thread类 源代码可以看出Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 setget方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的 get()set()方法。

ThreadLocal类的set()方法

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。

每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    //......
}

比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread内部都是使用仅有的那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值。

请添加图片描述

ThreadLocalMapThreadLocal的静态内部类。

请添加图片描述

5.3ThreadLocal 内存泄露问题

ThreadLocalMap 中使用的 keyThreadLocal弱引用,而 value强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()get()remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

弱引用介绍:

如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

6、CAS与原子操作

6.1CAS的概念

CAS的全称是:⽐较并交换(Compare And Swap)。在CAS中,有这样三个值:

  • V:要更新的变量(var)
  • E:预期值(expected)
  • N:新值(new)

⽐较并交换的过程如下:

判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程 更新了V,则当前线程放弃更新,什么都不做。

所以这⾥的预期值E本质上指的是“旧值”。

我们以⼀个简单的例⼦来解释这个过程:

  1. 如果有⼀个多个线程共享的变量 i 原本等于5,我现在在线程A中,想把它设 置为新的值6;
  2. 我们使⽤CAS来做这个事情;
  3. ⾸先我们⽤i去与5对⽐,发现它等于5,说明没有被其它线程改过,那我就把 它设置为新的值6,此次CAS成功, i 的值被设置成了6;
  4. 如果不等于5,说明 i 被其它线程改过了(⽐如现在 i 的值为2),那么我就 什么也不做,此次CAS失败, i 的值仍然为2。

在这个例⼦中, i 就是V,5就是E,6就是N。

那有没有可能我在判断了 i 为5之后,正准备更新它的新值的时候,被其它线程更 改了 i 的值呢?

不会的。因为CAS是⼀种原⼦操作,它是⼀种系统原语,是⼀条CPU的原⼦指令, 从CPU层⾯保证它的原⼦性 。

当多个线程同时使⽤CAS操作⼀个变量时,只有⼀个会胜出,并成功更新,其余均 会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然 也允许失败的线程放弃操作。

6.2 Java实现CAS的原理 - Unsafe类

前⾯提到,CAS是⼀种原⼦操作。那么Java是怎样来使⽤CAS的呢?我们知道,在 Java中,如果⼀个⽅法是native的,那Java就不负责具体实现它,⽽是交给底层的 JVM使⽤c或者c++去实现。

在Java中,有⼀个 Unsafe 类,它在 sun.misc 包中。它⾥⾯是⼀些 native ⽅法, 其中就有⼏个关于CAS的:

boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
boolean compareAndSwapInt(Object o, long offset,int expected,int x);
boolean compareAndSwapLong(Object o, long offset,long expected,long x);

当然,他们都是 public native 的。

Unsafe中对CAS的实现是C++写的,它的具体实现和操作系统、CPU都有关系。

Linux的X86下主要是通过 cmpxchgl 这个指令在CPU级完成CAS操作的,但在多处 理器情况下必须使⽤ lock 指令加锁来完成。当然不同的操作系统和处理器的实现 会有所不同,⼤家可以⾃⾏了解。

当然,Unsafe类⾥⾯还有其它⽅法⽤于不同的⽤途。⽐如⽀持线程挂起和恢复 的 park unpark LockSupport类底层就是调⽤了这两个⽅法。还有⽀持反射操 作的 allocateInstance() ⽅法。

6.3Atomic 原子类

6.3.1 介绍一下 Atomic 原 子类

上⾯介绍了Unsafe类的⼏个⽀持CAS的⽅法。那Java具体是如何使⽤这⼏个⽅法 来实现原⼦操作的呢? JDK提供了⼀些⽤于原⼦操作的类,在 java.util.concurrent.atomic 包下⾯。在 JDK 14中,有如下17个类:

请添加图片描述

从名字就可以看得出来这些类⼤概的⽤途:

  • 原⼦更新基本类型
    • AtomicInteger:整型原子类
    • AtomicLong:长整型原子类
    • AtomicBoolean:布尔型原子类
  • 原⼦更新数组
    • AtomicIntegerArray:整型数组原子类
    • AtomicLongArray:长整型数组原子类
    • AtomicReferenceArray:引用类型数组原子类
  • 原⼦更新引⽤
    • AtomicReference:引用类型原子类
    • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
    • AtomicMarkableReference :原子更新带有标记位的引用类型
  • 原⼦更新字段(属性)
    • AtomicIntegerFieldUpdater:原子更新整型字段的更新器
    • AtomicLongFieldUpdater:原子更新长整型字段的更新器
    • AtomicReferenceFieldUpdater:原子更新引用类型字段的更新器
6.3. 2AtomicInteger 的使用

AtomicInteger 类常用方法

public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

AtomicInteger 类的使用示例

使用 AtomicInteger 之后,不用对 increment() 方法加锁也可以保证线程安全。

class AtomicIntegerTest {
    private AtomicInteger count = new AtomicInteger();
    //使用AtomicInteger之后,不需要对该方法加锁,也可以实现线程安全。
    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}
6.3.3 AtomicInteger 类的原理

AtomicInteger 线程安全原理简单分析

AtomicInteger 类的部分源码:

// setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用)
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
    try {
        valueOffset = unsafe.objectFieldOffset
            (AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;

AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址,返回值是 valueOffset。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

6.4 CAS实现原⼦操作的三⼤问题

这⾥介绍⼀下CAS实现原⼦操作的三⼤问题及其解决⽅案。

6.4.1 ABA问题

所谓ABA问题,就是⼀个值原来是A,变成了B,⼜变回了A。这个时候使⽤CAS是 检查不出变化的,但实际上却被更新了两次。

ABA问题的解决思路是在变量前⾯追加上版本号或者时间戳。从JDK 1.5开始, JDK的atomic包⾥提供了⼀个类 AtomicStampedReference 类来解决ABA问题。

这个类的 compareAndSet ⽅法的作⽤是⾸先检查当前引⽤是否等于预期引⽤,并且 检查当前标志是否等于预期标志,如果⼆者都相等,才使⽤CAS设置为新的值和标 志。

public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp) {
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
    }

6.4.2 循环时间⻓开销⼤

CAS多与⾃旋结合。如果⾃旋CAS⻓时间不成功,会占⽤⼤量的CPU资源。

解决思路是让JVM⽀持处理器提供的pause指令

pause指令能让⾃旋失败时cpu睡眠⼀⼩段时间再继续⾃旋,从⽽使得读操作的频 率低很多,为解决内存顺序冲突⽽导致的CPU流⽔线重排的代价也会⼩很多。

6.4.3 只能保证⼀个共享变量的原⼦操作

有两种解决⽅案:

  1. 使⽤JDK 1.5开始就提供的 AtomicReference 类保证对象之间的原⼦性,把多个 变量放到⼀个对象⾥⾯进⾏CAS操作;
  2. 使⽤锁。锁内的临界区代码可以保证只有当前线程能操作。

7、AQS

7.1 AQS简介

AQS是 AbstractQueuedSynchronizer 的简称,即 抽象队列同步器 ,从字⾯意思上理解:

  • 抽象:抽象类,只实现⼀些主要逻辑,有些⽅法由⼦类实现;
  • 队列:使⽤先进先出(FIFO)队列存储数据;
  • 同步:实现了同步的功能。

那AQS有什么⽤呢?AQS是⼀个⽤来构建锁和同步器的框架,使⽤AQS能简单且 ⾼效地构造出应⽤⼴泛的同步器,⽐如我们提到的ReentrantLock,Semaphore, ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS 的。

当然,我们⾃⼰也能利⽤AQS⾮常轻松容易地构造出符合我们⾃⼰需求的同步器, 只要子类实现它的⼏个 protected ⽅法就可以了。

7.2 AQS 原理概览

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。

原理图:

AQS原理图

AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。

ivate volatile int state;//共享变量,使用volatile修饰保证线程可见性

状态信息通过 protected 类型的 getState,setState,compareAndSetState 进行操作

//返回同步状态的当前值
protected final int getState() {
    return state;
}
//设置同步状态的值
protected final void setState(int newState) {
    state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

7 .3 资源共享模式

资源有两种共享模式,或者说两种同步⽅式:

  • 独占模式(Exclusive):资源是独占的,⼀次只能⼀个线程获取。如 ReentrantLock。
  • 共享模式(Share):同时可以被多个线程获取,具体的资源个数可以通过参 数指定。如Semaphore/CountDownLatch。

⼀般情况下,⼦类只需要根据需求实现其中⼀种模式,当然也有同时实现两种模式 的同步类,如 ReadWriteLock 。

AQS中关于这两种资源共享模式的定义源码(均在内部类Node中)。我们来看看 Node的结构:

static final class Node {
     // 标记⼀个结点(对应的线程)在共享模式下等待
     static final Node SHARED = new Node();
     // 标记⼀个结点(对应的线程)在独占模式下等待
     static final Node EXCLUSIVE = null;
    
     // waitStatus的值,表示该结点(对应的线程)已被取消
     static final int CANCELLED = 1;
     // waitStatus的值,表示后继结点(对应的线程)需要被唤醒
     static final int SIGNAL = -1;
     // waitStatus的值,表示该结点(对应的线程)在等待某⼀条件
     static final int CONDITION = -2;
     //waitStatus的值,表示有资源可⽤,新head结点需要继续唤醒后继结点
     static final int PROPAGATE = -3;
    
     // 等待状态,取值范围,-3,-2,-1,0,1
     volatile int waitStatus;
     volatile Node prev; // 前驱结点
     volatile Node next; // 后继结点
     volatile Thread thread; // 结点对应的线程
     Node nextWaiter; // 等待队列⾥下⼀个等待条件的结点
    
     // 判断共享模式的⽅法
     final boolean isShared() {
    	 return nextWaiter == SHARED;
     }
     Node(Thread thread, Node mode) { // Used by addWaiter
         this.nextWaiter = mode;
         this.thread = thread;
     }
    	... // 其它⽅法忽略,可以参考具体的源码
    }


    // AQS⾥⾯的addWaiter私有⽅法
    private Node addWaiter(Node mode) {
    	 // 使⽤了Node的这个构造函数
    	 Node node = new Node(Thread.currentThread(), mode);
     	 ...	// 其它代码省略
}

7.4 AQS 底层使用了模板方法模式

AQS的设计是基于模板⽅法模式的,它有⼀些⽅法必须要⼦类去实现的,它们主要 有:

protected boolean tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
protected boolean tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
protected int tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
protected boolean tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。
protected boolean isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。

7.5 AQS 组件总结

  • Semaphore(信号量)-允许多个线程同时访问: synchronizedReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
  • CountDownLatch (倒计时器): CountDownLatch 是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。
  • CyclicBarrier(循环栅栏): CyclicBarrierCountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

详细AQS源码分析请看飞哥https://csp1999.blog.youkuaiyun.com/article/details/116604866

评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值