一、概述
本系列并发文章主要是为了解决日常面试中经常问到的概念进行相关梳理,各种锁估计都将各位绕晕了。
大家可以先看看下面的问题,在针对性的阅读本系列文章。
- synchronized底层原理是什么
- JMM可见性,原子性,有序性,synchronized可以保证什么?
- 说说你对volatile字段有什么用途?
- sychronized,volatile区别? 这里可以体系化的回答,主要从JMM角度去回答,最后深入到字节码层面的区别。因为提到了修饰的范围有差别,就有了下一题。对于锁的对象的不同,效果会有什么差别。
上下文切换
即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停的切换线程执行,让我们感觉多个线程同时执行的,时间片一般是几十毫秒(ms)。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
CPU术语
| 概念 | 解释 |
|---|---|
| 内存屏障 | 是一组处理器指令,用于实现对内存操作的顺序限制 |
| 缓冲行 | CPU高速缓存中可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行。 |
| 原子操作 | 不可中断的一个或一系列操作 |
| 缓存行填充 | 当处理器识别到内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存 |
| 缓存命中 | 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取 |
| 写命中 | 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在,则处理器将这个数写回到缓存,而不是写到内存 |
| 写缺失 | 一个有效的缓存行被写入到不存在的内存区域 |
二、 volatile
volatile是轻量级的 synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值 。如果一个字段被声明成 volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。
volatile 是如何保证可见性的呢?
- Lock前缀指令会引起处理器缓存回写到内存
- 一个处理器的缓存回写到内存会导致其他处理器的缓存无效
如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态下,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
三、 synchronized
对于基础使用可以看下这篇文章synchronized基本使用
先看下synchronized实现同步的基础:java中的每一对象都可以作为锁。具体表现为以下三种形式
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的Class对象。
- 对于同步方法块,锁是
synchronized括号里配置的对象。
在 Java 1.6 时,synchronized 做了大量优化,引入了轻量级锁和偏向锁。此时锁有四种状态,分别是无锁、偏向锁、轻量级锁和重量级锁。这几个状态会随着竞争情况逐渐升级,锁可以升级但不能降级
3.1 java对象头
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。

- 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
- 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word(存储对象的hashCode或锁信息等) 和 Class Metadata Address(存储到对象类型数据的指针) 组成,
synchronized 用的锁的信息是存放在 Java 对象头的 Mard Word 标记字段中的,它里面保存了对象的 HashCode、分代年龄和锁标志位。锁标志位用两个 bit 表示,00 表示轻量级锁,10 表示重量级锁,01 表示偏向锁和无锁,它们两个再用一个 bit 表示是否是偏向锁。
3.2 synchronized在JVM里的实现原理
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,对于锁代码块,其实就在代码块的前后增加一对 monitorenter 和 monitorexit 指令。
任何一个对象都有一个 monitor与之关联,并且一个monitor被持有后,它将处于锁定状态。线程执行到 monitorenter指令时,将会尝试获取对象头所对应的monitor的所有权,即尝试获得对象的锁。
//Synchorized.java
public class Synchorized {
public static void main(String[] args){
synchronized (Synchorized.class){
}
m();
}
public static synchronized void m(){
}
}
先用 javac来将该类变成 .class文件。
我们用 javap -v来看下 Synchorized.class来反编译看下字节码相关信息。
//这边截取部分
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class main/threads/Synchorized
2: dup
3: astore_1
4: monitorenter
5: aload_1
6: monitorexit
7: goto 15
10: astore_2
11: aload_1
12: monitorexit
13: aload_2
14: athrow
15: invokestatic #3 // Method m:()V
18: return
上面的字节码中包含一个 monitorenter 指令以及多个 monitorexit 指令。这是因为 Java 虚拟机需要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁。
这里 monitorenter 和 monitorexit 操作所对应的锁对象是隐式的。对于实例方法来说,这两个操作对应的锁对象是 this;对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例。
关于 monitorenter 和 monitorexit 的作用,我们可以抽象地理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。当执行 monitorenter 时,如果目标锁对象的计数器为 0,那么说明它没有被其他线程所持有。在这个情况下,Java 虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加 1。
在目标锁对象的计数器不为 0 的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加 1,否则需要等待,直至持有线程释放该锁。当执行 monitorexit 时,Java 虚拟机则需将锁对象的计数器减 1。当计数器减为 0 时,那便代表该锁已经被释放掉了。
之所以采用这种计数器的方式,是为了允许同一个线程重复获取同一把锁。举个例子,如果一个 Java 类中拥有多个 synchronized 方法,那么这些方法之间的相互调用,不管是直接的还是间接的,都会涉及对同一把锁的重复加锁操作。因此,我们需要设计这么一个可重入的特性,来避免编程里的隐式约束。
四 java中的锁的状态
锁的状态分为 无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
4.1 偏向锁
在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入偏向锁。当一个线程访问同步块并获取锁时,会在对象头里记录锁偏向的线程 ID,下次该线程再次进入只需要判断线程 ID 就可以了。
4.2 轻量级锁 & 重量级锁
轻量级锁就是在获取锁的时候,**如果获取不到就通过自旋来获取锁,也就是自旋锁,如果在指定次数没有成功,就会膨胀为重量级锁,**重量级锁在竞争时会阻塞其他线程。
- 轻量级锁竞争的线程不会阻塞,提高了程序的响应速度。如果始终得不到锁竞争的线程,使用自旋会消耗CPU,适用于追求响应时间
- 重量级锁线程竞争不使用自旋,不会消耗CPU,线程阻塞,响应时间缓慢,追求吞吐量。
4.3 小结
偏向锁
偏向锁只会在第一次请求时采用 CAS 操作,在锁对象的标记字段中记录下当前线程的地址。在之后的运行过程中,持有该偏向锁的线程的加锁操作将直接返回。它针对的是锁仅会被同一线程持有的情况。轻量级锁
轻量级锁采用 CAS 操作,将锁对象的标记字段替换为一个指针,指向当前线程栈上的一块空间,存储着锁对象原本的标记字段。它针对的是多个线程在不同时间段申请同一把锁的情况。重量级锁
重量级锁会阻塞、唤醒请求加锁的线程。它针对的是多个线程同时竞争同一把锁的情况。Java 虚拟机采取了自适应自旋,来避免线程在面对非常小的 synchronized 代码块时,仍会被阻塞、唤醒的情况。
五、原子操作的实现原理
原子操作可被理解为 不可中断的一个或一系列操作。
| 概念 | 解释 |
|---|---|
| 缓存行 | 缓存的最小操作单位 |
| 比较并交换 | CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换 |
CAS 即比较并替换,它的通过硬件来保证操作的原子性。
原子类即是指 Java 中的 Atomic 类,比如 AtomicInteger、AtomicLong、AtomicStampedReference、AtomicReference 等。都是通过 CAS 来做的。在 Java 中,UnSafe 类提供了对 CAS 的简单封装,Atomic 类内部也都是使用 UnSafe 类来做的,UnSafe 类是可以直接操作内存的,一般在应用程序中是不能使用的,它是由启动类加载器加载的。
5.1 CAS存在的问题和解决方案
- ABA问题
CAS需要在操作值的时候,检查值有没有发生变化,如果没有变化则发生更新。但是一个值由A变成B再变成A,实际值发生了变化。解决方案通过加一个版本号,可以使用 AtomicStampedReference 来解决。
循环时间开销大
这个问题的解决可以参考 Java8 新增的 LongAdder 类。在高并发场景下,AtomicLong 会导致大量线程自旋,严重损耗 CPU,这时候可以把 long 值分为一个 base 加上一个 Cell 数组,也就是把竞争分到多个 Cell 上,最后取值时就是 base 加上多个 Cell 的值。只能保证一个共享变量的原子操作
解决办法就是可以把多个共享变量合成一个共享变量,比如 ThreadPoolExecutor 的 ctl 字段包含了线程池状态和 Worker 线程数量。或者可以使用 AtomicReferecne 类来保证引用对象之间的原子性,也就是把多个变量放在一个对象里进行 CAS 操作。
六、常见问题回答
6.1 synchronized底层原理是什么
具体参考 节3、节4,能回到出字节码中的 monitorenter 指令以及多个 monitorexit 指令相关内容。
6.2 JMM可见性,原子性,有序性,synchronized可以保证什么?
回答这道题我们需要理解 JMM(Java 内存模型 )的三大性质(原子性、可见性、有序性)代表什么含义
原子性
原子性指的是 不可被中断的一个或一系列操作 。 举个例子,比如多线程同时对一个共享变量进行操作,由于JMM的内存模型的关系 ,假设i =1,我们进行两次 i++操作,我们期望的结果是3,但也可能是2。根据原子性的定义,每一步都应该被执行到,结果值应该和期望值一样都是3 。 显然,我们可以用synchronized加锁来保证原子性。可见性
可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。
对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。
但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题有序性
有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,此时需要加锁来确保代码的顺序执行问题。
小结一下,理解了JMM三大性质,synchronized能保证原子性和可见性以及有序性
参考
《java并发编程的艺术》