简介
本系列为《Java并发编程的艺术》读书笔记。在原本的内容的基础上加入了自己的理解和笔记,欢迎交流!
chapter 2:Java并发机制的底层实现
之前学习Java多线程的时候,使用过synchronized
,这种锁称为重锁,而volatile
被称为轻量锁,具有可见性,可见性是指一个线程修改一个共享变量时,其他线程可以读取修改后的值 。
书中给出了一些术语,之后会频繁出现,作者介绍详尽,不再补充:
volatile关键字
在Java中,如果一个变量被该关键字修饰,那么所有线程看到这个变量的值一定是相同的(共享)。
//在类中声明
volatile instance = new Singleton();
// 使用工具查看生成的汇编代码,会发现在声明变量的后面多一句内容:
Lock addl $0x0, (%esp);
Lock
指令会引发CPU将指定位置的数据写回到内存中,一旦对应的数据发生了改变,所有cache中的对应的记录都会变成脏数据,因为计算机中的高速缓存必须维持数据的版本一致。这种协议称为缓存一致协议。
通常 来说,每个CPU有自己对应的cache,而且cache通常有多级。当一个Lock信号
在总线上广播时,别的CPU会拦截写回内存的地址,并去自己对应的缓存中检测是否命中,如果命中则将对应的缓存行的脏读位置为1。总的来说,volatile实现的原则有两点:
- Lock指令触发处理器缓存写回到内存中;
- 一致性协议使得别的处理器的缓存中对应的缓存行失效,所以就必须去内存中读取值。
就目前而言,处理器的操作过程是:所有持有该数据的处理器的cache会将对应的缓存行锁定(因为数据是被修改了,所有不允许别的处理器来访问这些脏数据),通过缓存一致性协议保证修改被写回到内存中。该操作称为缓存锁定。
Synchronized关键字
Java中每个对象都是可以作为锁,具体情况可以划分成以下几种:
- 普通对象的锁是当前实例对象;
- 静态同步方法,锁是当前类的Class对象;
- 同步方法块,锁是
Synchronized
括号内的对象,即1,2。
JVM中的线程想要访问同步代码块必须获取锁,基于的是Monitor
对象。代码的块的同步是使用monitorenter
和monitorexit
指令实现的,这两个指令会在Java编译成字节码的时候被插入在指定的位置。
同步代码块在编译时会在开头插入monitorenter,在结束处和异常处插入monitorexit。
Java对象头
每个对象都会有对象头,用来存储一些元数据。synchronized锁的相关信息就存储在对象头。
之所以每个对象都可以作为一个锁,是因为在java对象头中保存了对应的信息。前面提到的CAS算法其实就用来修改Java对象头的。
数组对象的对象头包括3个字长,比非数组对象多一字长用于表示数组的长度。
Mark word的内容如下:
- 对象的HashCode
- 分代年龄
- 锁标记位
锁的升级与比较
在Java 1.6之后,锁一共有四种状态,对应的优先级从低到高:
1. 无锁
即没有加锁。
2. 偏向锁:只有一个线程访问锁
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。如果发生了线程对数据的争用,那么偏向锁就会撤销。
注意:偏向锁只是加速了一个线程多次访问一个同步块,且没有发生争用,适用于线程对锁的竞争度不高的情况。
偏向锁的作用主要是加速线程争用情况不严重的场景,锁标记指示当前对象持有的偏向锁,此时线程会检查markword中的线程ID是否是自己,如果不是自己那么去检查是否设置了偏向锁标志:
- 设置为1:使用CAS将当前线程id修改到线程ID:
- 成功:获取偏向锁; 此时 :
TID|Epoch|对象分代年龄|1|01
- 失败:撤销偏向锁;此时:
null|Epoch|对象分代年龄|0|01
。此后偏向锁失效,因为发生了锁争用。
- 成功:获取偏向锁; 此时 :
- 设置为0:那么就去竞争锁,此时偏向锁失效,偏向锁的撤销必须是在安全点。
3. 轻量级锁:适用于争用情况不严重的场景
加锁
线程在访问同步代码块之前,会在当前的线程的系统栈中创建存储锁记录的空间(多个锁)。
每访问一个同步代码块就会将这个锁对象(A对象)的mark word
(理解为对象唯一标识)复制到该空间中,并使用CAS(原子性)操作将A对象的mark word
中的对应的字段修改成当前的线程锁记录空间的地址。这里的理解就是,一旦发现当前的mark word
被修改了,那么别的线程使用CAS操作就无法修改,会进入自旋或者锁膨胀。
这个过程就是将对象A的mark word(唯一标识)拿走,放到自己的私有区域(线程栈),并告诉别人现在这个锁被获取了(因为原本存储mark word的位置存储到了一个锁空间的地址)。
如果成功则获取锁,如果失败则使用自旋的方式不断尝试获取锁;
解锁
解锁就很好理解了。和加锁的过程相反,就是把原本拿走的mark word
还回去,表示我用完了,当别的线程再次访问这个对象时,会发现这个位置的值确实是对象本身的mark word
,此时就会获取成功。
如果此时有线程在争用这个锁,那么轻量级锁就会失效,升级成重量锁,那么拿到锁的线程在使用CAS解锁就会失败,此时释放锁的线程需要唤醒因为该锁而阻塞的线程。
4. 重量级锁:多个线程同时访问一把锁
由于锁的争用且自旋一段时间无法获取锁,此时线程就会将当前的锁修改成重量级锁。重锁会让当前线程进入阻塞状态,会被加入到同步队列中。
原子性操作
先明确一些术语:
首先我们来看一下处理器(32位的IA-32处理器)是如何实现原子性操作的:
- 对总线进行加锁:处理器提供一个LOCK信号,当有一个
LOCK
信号被输出到总线上时,其他处理器的请求将被阻塞。但是CPU和内存之间的通信也会被阻塞。 - 对缓存机进行加锁:总线加锁使得其他的处理器不可以操作其他内存地址的数据。如果一个内存地址的数据被缓存到了cache中,那么在执行锁操作期间,当发生了写回动作时会去更新缓存行中的数据,利用缓存一致性来保证数据的可靠性。
- 缓存一致性: 当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中的缓存该变量的缓存行是无效的,那么它就会重新从内存中读取。
作者详尽的表述了Java如何实现原子性操作的,总结起来就是锁和循环的CAS操作。不断地使用CAS操作尝试去修改状态,如果成功则代表获取到了锁。
CAS存在的问题和解决办法:
- ABA问题:引入版本号;
- 循环时间过长:使用pause指令
- 延迟流水线的执行;
- 避免在退出循环时因为内存的冲突引发流水线清空;
- 无法保证多个变量的共享操作:
- 使用锁;
- 将多个共享变量