线程模型
了解Java中的线程模型前推荐先了解JVM中的内存模型(JMM),推荐阅读我写的这篇文章《JVM 内存模型》。
JVM线程与操作系统线程之间存在着某种映射关系,这两种不同维度的线程之间的规范和协议,就是线程模型。
JVM线程对不同操作系统上的原生线程进行了高级抽象,使开发者在大多数情况下可以不用关注下层细节,而只要专注于上层开发。
Java锁机制
JVM内存结构由五部分组成:程序计数器、虚拟机栈、本地方法栈、堆、方法区。
其中,堆、方法区的数据是线程共享的。当多个线程竞争同一些数据时,容易产生异常情况。Java中的锁就是用于解决这个问题。
Java中的锁存在两种实现方式:
- 基于对象的悲观锁
- 基于CAS的乐观锁
基于Object的悲观锁
在Java中,每个对象都拥有一把锁,这把锁存储在对象头中,用于记录当前对象被哪个线程占用。
Java对象分为三个部分:
- 对象头(下面细讲)
- 实例数据(对象中的属性、状态等的值)
- 对齐填充字节(该设计用于满足”Java对象大小是8字节倍数“的条件)
对象头一般设计得很小,其中包含两部分:
- Mark Word
- Class Pointer
Class Pointer顾名思义是个指针,指向当前对象类型所在方法区中的类的信息
Mark Word存储了很多关于当前对象的运行时状态信息,如HashCode、锁状态标志、指向锁记录的指针、偏向线程ID、锁标志位等等。
关于Mark Word中的信息可以参考下图:
在上表中的锁标志位当中,分别对应 无锁、偏向锁、轻量级锁、重量级锁、GC标记 五种状态。
在Java中,启用对象锁的方式是通过使用synchronized
关键字来进行的。
synchronized关键字
synchronized关键字可以用于同步线程。synchronized被编译后会生成 monitorenter 和 monitorexit 两个字节码指令。
Monitor,即监视器。一个线程进入Monitor时,其它线程如果也想进入Monitor,只能等待直到Monitor中的线程退出才能进入。
这也即是synchronized关键字实现同步机制的方式,但是这种方式存在性能问题,因为Monitor的下层是通过操作系统中的Mutex Lock(互斥锁)来实现的,这其中线程的挂起和唤醒这两个操作进行了两次上下文切换,因此这种方式会涉及到大量的上下文切换,消耗大量CPU,影响性能。
什么是上下文切换?
线程多,不断阻塞、唤醒:一次阻塞+唤醒操作需要两次上下文切换,就是第一次由用户态切换到内核态,由内核将线程阻塞掉,将线程数据原本在寄存器或者cache运送到内存或队列中,第二次由用户态切换到内核态,由内核将线程唤醒,将线程数据从内存或者队列送到寄存器或cache中。如果频繁进行上下文切换,CPU就大多数消耗在内核数据共享中,影响了真正工作线程功能。
上下文切换过高会导致CPU像个搬运工,频繁在寄存器和运行队列之间奔波,更多的时间花在了线程切换,而不是真正工作的线程上。直接的消耗包括CPU寄存器需要保存和加载,系统调度器的代码需要执行。间接消耗在于多核cache之间的共享数据。
因此在JDK1.6之后,synchronized进行了优化,引入了 “偏向锁”、“轻量级锁” 的概念。
下面我们讨论一下 无锁、偏向锁、轻量级锁、重量级锁 四种状态。
无锁:
无锁顾名思义就是不对资源进行操作系统级别(Mutex Lock)的锁定。
两种情况下适合使用无锁:
- 资源在多线程环境下不会出现线程竞争的情况
- 资源会被竞争,但是可以使用CAS等函数级别的锁来进行控制同步
偏向锁:
当给对象加锁后,实际上却只有一个线程会获取这个对象锁时,我们希望只在用户态来实现同步控制,那么我们可以让对象锁认识这个线程,每次遇到这个线程时就把锁交出去,所以可以理解为偏爱这个线程,即“偏向锁”。
偏向锁的实现是通过用Mark Word中来记录“所偏爱”线程的ID。
当对象发现当前不止一个线程在竞争锁时,偏向锁会升级为轻量级锁。
轻量级锁:
当偏向锁升级为轻量级锁时,Mark Word中记录的不再是线程ID,而是一个指向虚拟机栈中锁记录的指针。
当一个线程尝试获得锁并发现该锁是轻量级锁时,会在自己的虚拟机栈中开辟一块叫“锁记录”的空间,该锁记录中记录着锁对象的Mark Word以及一个Owner指针,Owner指针用于指向该锁对象,同时锁对象的Mark Word中也会生成一个指针指向该线程虚拟机栈中的锁记录。
也即是说,锁对象有一个指针指向该线程记录中的锁记录,线程的锁记录中也有一个记录指向该锁对象(同时存储锁对象之前的Mark Word),那么它们就互相意识到对方的存在。
当其它线程想来获取该锁对象但发现该锁对象正在被占用时,会自旋等待。自旋就是对象会在一个循环中不断尝试获取锁对象,这个过程是不需要操作系统来挂起和阻塞的,效率相对较高。但与此同时,自旋也相当于CPU在空转,当有过多的线程同时进行自旋,也会浪费CPU资源。
所以当自旋等待的线程超过1个时,轻量级锁就会升级为重量级锁。
重量级锁:
重量级锁就是使用Monitor来对线程进行同步控制的,对线程的控制也最严格,且同步机制上文也有简要介绍,这个过程会涉及上下文切换,性能消耗相对较大。
锁升级:
锁升级其实就是上文所提到的锁升级的一个过程:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
- 当只有一个线程需要占用资源时,其实锁对象使用偏向锁
- 当有两个需要抢占资源时,偏向锁升级为轻量级锁
- 当多于一个线程在自旋等待时,轻量级锁升级为重量级锁
锁消除:
即时编译器(JIT编译器)在进行动态编译代码时,会使用一种叫逃逸分析的技术,判断程序中的锁对象是否只被一个线程使用,如果是,JIT编译器在编译这个同步代码时就不会生成synchronized关键字来标识锁申请与释放的机器码,从而实现了锁的消除,已提高性能。
如以下代码:
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("a");
stringBuffer.append("b");
StringBuffer使用的是同步方法,但是这里这个同步方法只会被一个线程使用,那么JIT编译器会进行优化,消除了锁。
锁粗化:
当JIT编译器在动态编译时,如果发现前后相邻的synchronized代码块使用的是同一个锁对象,那么它会把这多个同步块合并为一个大的同步块,这样当线程在执行这些代码时,就无需频繁申请和释放锁了,进而提高性能。
例如有以下代码块:
synchronized (this) {
i++;
}
synchronized (this) {
i++;
}
那么在JIT编译器进行优化后,会等效于以下代码块:
synchronized(this){
i++;
i++;
}
基于CAS的乐观锁
什么是乐观锁?乐观锁与悲观锁的区别是什么?
在这里我们需要先讨论一下为什么分别说它们是乐观的还是悲观的。
悲观锁中的悲观,是说操作系统会悲观地认为,如果不进行严格的线程同步,就会出现异常,所以操作系统会使用互斥锁的同步方式来锁定资源,让资源只供一个线程使用,阻塞其他线程。
乐观锁中的乐观,是说线程每次去拿共享资源的数据时,都认为别人不会修改这个共享资源的值,所以不需要进行上锁。当线程发现共享资源正在被占用时,也会反复去尝试获取这个共享资源。当线程更新共享资源数据时,会判断此期间是否有其它资源更新了这个数据,这时可以使用版本号等机制(用于解决ABA问题,下面会提到)。
CAS算法:
Java的Unsafe类中有一个native方法叫compareAndSwap,简称也即是CAS方法,用于实现CAS算法。
CAS的应用场景:
- JUC包中Atomic原子类的实现
- 实现多线程对共享资源竞争的互斥性质,比如AQS、ConcurrentHashMap、ConcurrentLinkedQueue
利用CAS算法,我们就可以实现乐观锁的机制。关于CAS,可以通过一个例子来理解:
假设现在有一个更衣室,更衣室一次只允许一个人使用,更衣室门前有个牌子,当牌子内容是0时,意味着更衣室内没人;当牌子内容是1时,意味着更衣室内有人。
当有人想尝试使用这个更衣室时,会先看牌子上的内容是否为0,如果是,那么他会将牌子上的内容更改为1,然后进入更衣室;如果不是,那么他也不会放弃,会不断过来看牌子上的内容更改为0了没(期间他也可以去做其他事情)。
在以上例子中,更衣室即代表共享资源,牌子就是一把乐观锁,人就是线程。
设A、B两个线程同时打算将一个共享资源的值由0修改为1,它们最先都会乐观地认为当前资源是没有人进行占用,于是它们会各自生成两个值:
- 旧值,即之前读到的资源的值,在这里为0
- 新值,即计划中资源修改后的值,在这里为1
A、B线程会抢着去修改共享资源的值,在修改资源之前,会将当前资源的值与旧值比较,如果是一致,则认为共享资源没有被修改过(注意这里,与下面的ABA问题有关),此时将值修改为1;如果当前资源的值与旧值不一致,则说明当前共享资源已经被修改过了,那么它会放弃当前的修改操作,同时不断重新尝试进行。
但是在这里我们不难发现一个问题,就是当A、B线程在将共享资源的值与旧值比较发现一致后,如果在将共享资源的值修改为新值之前,有其它线程进行了值的修改,这时候就出现了异常的情况。所以,“比较数值是否一致并修改数值” 这两个动作需要同时进行,也就是说,CAS的操作必须是原子性的。现在各种不同架构的CPU都提供了指令级的CAS原子操作,只需上层直接进行调用即可。
ABA问题:
ABA问题其实很好理解,上面提到,线程在修改资源之前,会将当前资源的值与旧值比较,如果是一致,则认为共享资源没有被修改过。但是事实上存在一种情况,就是其实这个共享资源是被修改过的,只是修改后的值与当前线程修改资源前的值相同。
打个比方,小明打算去便利店买瓶无糖可乐,他出门前看了下银行卡余额,此时是有10块钱。然后他出门走到了便利店拿了瓶无糖可乐,在付款之前,他确认了自己的银行卡余额,依然是10块钱,于是他认为他的银行卡余额没有变化过,于是放心地付了钱。
但其实在这个过程中,其实发生了两件事,一件是小明的女朋友使用了小明银行卡的代付消费了5块钱,另一件是小明的好哥们向小明的银行卡转账了之前向小明借的5块钱。
当我们不在乎共享数据是否被修改过时,也即ABA问题不会对我们的结果产生什么不良影响时,我们可以不去关心ABA问题。
那什么时候ABA问题会产生不良影响?
假设并发线程1打算对一个栈进行操作。在操作之前的栈是这样的:
并发线程1在操作前了解到此时栈顶元素是A1,然后开始进行它的操作,并打算之后将栈顶元素A1修改为A2。
在并发线程1操作期间,栈发生了三次出栈操作,此时栈变为只有两个元素。此时并发线程1进行完了它的操作,并对栈顶元素进行判断是否是A1,来判断栈是否被其它线程修改过。
这时候栈顶元素仍然是A1,于是并发线程1认为栈没有被其它线程修改过,于是它将A1修改为了A2:
但其实此时的A1并非之前的A1,所以就产生了ABA问题!
如果我们介意ABA问题的出现,那么我们就应该使用带版本号的CAS算法来实现乐观锁机制。
带版本号的CAS算法对原本的CAS算法进行了优化:
共享资源的数据带有一个版本号,当每一次数据被修改时,版本号会迭加。
CAS在修改数据之前不能只比对共享资源的值,还需要确保当前数据的版本号不变!
如果共享资源的值相同,但版本号发生了变化,此时也不应该进行共享资源值的修改。
之前关于Java并发的学习之后总是忘了部分内容,每次都需要重新去看资料。于是今天我做了这篇笔记,用于记录自己对Java并发的学习。
之后我也会更新关于Java并发的文章,包含AQS、Reentranlock、volatile、ThreadLocal、线程池等内容。
如果你喜欢这篇文章,不妨给我个点赞吧。
参考: