目录
- 1.synchronized
- 1.1.synchronized 是什么?有什么作用?
- 1.2.如何使用 synchronized?
- 1.3.synchronized 的底层原理是什么?
- 1.3.1.synchronized 同步语句块
- 1.3.2.synchronized 修饰方法
- 13.3.总结
- 1.4.JDK1.6 之后的 synchronized 底层做了哪些优化?
- 1.5.早期的 synchronized 为什么被称为重量级锁?请从操作系统层面来说明。
- 1.6.synchronized 和 volatile 有什么区别?
- 2.volatile
- 2.1.volatile 是什么?有什么作用?
- 2.2.volatile 的内存语义是什么样的?
- 2.2.1.内存可见性
- 2.2.2.禁止重排序
- 2.3.volatile 有哪些用途?
前置知识:
Java 内存模型基础知识
重排序与 happens-before
1.synchronized
1.1.synchronized 是什么?有什么作用?
(1)synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
(2)在 Java 早期版本中,synchronized 属于重量级锁,效率低下。这是因为监视器锁 (monitor) 是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
(3)不过,在 Java 6 之后, synchronized 引入了大量的优化,例如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此,synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized。
1.2.如何使用 synchronized?
synchronized 关键字的使用方式主要有下面 3 种:
(1)修饰实例方法 (锁当前对象实例)
给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。
synchronized void method() {
//业务代码
}
(2)修饰静态方法 (锁当前类)
给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前 class 的锁。这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。
synchronized static void method() {
//业务代码
}
思考:静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?
答:不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
(3)修饰代码块 (锁指定对象/类)
对括号里指定的对象/类加锁:
- synchronized(object):表示进入同步代码库前要获得给定对象的锁;
- synchronized(类.class):表示进入同步代码前要获得给定 Class 的锁;
synchronized(this) {
//业务代码
}
总结:
- synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
- synchronized 关键字加到实例方法上是给对象实例上锁;
- 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。
注意:构造方法不能使用 synchronized 关键字修饰,其原因在于构造方法本身就属于线程安全的,不存在同步的构造方法一说。
1.3.synchronized 的底层原理是什么?
public class Synchronized {
public static void main(String[] args) {
// 对Synchronized Class对象进行加锁
synchronized (Synchronized.class) {
}
// 静态同步方法,对Synchronized Class对象进行加锁
m();
}
public static synchronized void m() {
}
}
在 Synchronized.class 同级目录执行 javap–v Synchronized.class
,部分相关输出如下所示:
public static void main(java.lang.String[]);
// 方法修饰符,表示:public staticflags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: ldc #1 // class com/murdock/books/multithread/book/Synchronized
2: dup
3: monitorenter // monitorenter:监视器进入,获取锁
4: monitorexit // monitorexit:监视器退出,释放锁
5: invokestatic #16 // Method m:()V
8: return
public static synchronized void m();
// 方法修饰符,表示: public static synchronized
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=0, args_size=0
0: return
1.3.1.synchronized 同步语句块
由上述信息可知,synchronized 同步语句块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取对象监视器 monitor 的持有权。在执行 monitorenter 时,会尝试获取对象的锁:
- 如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
- 如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
1.3.2.synchronized 修饰方法
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。
13.3.总结
(1)无论采用哪种方式,对于同步块的实现的本质是对一个对象的监视器 (monitor) 进行获取,而这个获取过程是排他的,也就是同一时刻只能有一个线程获取到由synchronized所保护对象的监视器。
(2)任意一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用
时,执行方法的线程必须先获取到该对象的监视器才能进入同步块或者同步方法,而没有获取到监视器(执行该方法)的线程将会被阻塞在同步块和同步方法的入口处,进入 BLOCKED 状态。
(3)下图描述了对象、对象的监视器、同步队列和执行线程之间的关系:
从上图中可以看到,任意线程对 Object(Object 由 synchronized 保护)的访问,首先要获得 Object 的监视器。如果获取失败,线程进入同步队列,线程状态变为 BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。
有关抽象队列同步器的相关知识可以参考Java 并发编程面试题——Lock 与 AbstractQueuedSynchronizer (AQS)这篇文章。
1.4.JDK1.6 之后的 synchronized 底层做了哪些优化?
(1)JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销:
- 偏向锁 (Biased Locking):偏向锁是 JVM 对 synchronized 关键字的一种优化,用于减少无竞争情况下的锁操作开销。偏向锁的核心思想是默认情况下,一个线程第一次获得锁时,将对象头中的标志设置为偏向锁,并将线程ID记录在对象头中,之后该线程再次请求锁时,无需进行同步操作即可获取锁。
- 轻量级锁 (Lightweight Locking):在多个线程之间进行竞争的情况下,JVM 将使用轻量级锁来提高性能。轻量级锁通过使用CAS(比较并交换)操作对对象头进行加锁和解锁,避免了重量级锁的开销。如果出现竞争,轻量级锁会升级为重量级锁。
- 自旋锁 (Spin Lock):自旋锁是在获取锁时,线程不会被阻塞,而是执行忙等待,不断尝试获取锁。自旋锁适用于锁持有者保持锁时间很短且线程间竞争不激烈的情况。如果锁竞争激烈或锁持有时间较长,自旋锁会浪费 CPU 时间。
- 锁粗化 (Lock Coarsening):锁粗化是将多个细粒度的连续加锁、解锁操作合并为一个更大范围的加锁、解锁操作,减少了加锁、解锁的次数和开销。这个优化避免了频繁的加锁、解锁对性能的影响。
- 锁消除 (Lock Elimination):锁消除是在编译器层面进行的优化,根据程序的静态分析,判断某些锁是多余的,可以被消除。例如,当编译器确定某个对象只在单线程中被访问时,就可以将其相关的锁消除。
(2)锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。锁的优缺点的对比以及使用场景如下所示:
1.5.早期的 synchronized 为什么被称为重量级锁?请从操作系统层面来说明。
(1)在操作系统层面,synchronized 锁是重量级锁(也称为互斥锁或内核级锁),主要是因为它涉及到操作系统的内核态与用户态之间的上下文切换。当一个线程尝试进入一个被 synchronized 修饰的临界区时,如果该临界区已被其他线程持有,那么该线程就会被阻塞,进入阻塞状态。这个阻塞操作是由操作系统内核来实现的,并且涉及到内核态和用户态之间的切换,这是由于内核在操作系统的核心中运行,而且具有更高的权限。这个上下文切换过程是相对复杂且开销较大的,所以 synchronized 锁被称为重量级锁。
(2)在具体的实现中,JVM 通过操作系统提供的原生线程机制来实现线程同步,而原生线程机制是操作系统内核级别的,会涉及到内核的调度、线程的阻塞、唤醒等操作。当一个线程被阻塞后,它会让出 CPU 的执行权,并进入阻塞队列等待被唤醒。而唤醒线程的操作也需要操作系统内核的参与。
(3)由于重量级锁的机制涉及到内核调度和上下文切换,所以在并发量较高的情况下,频繁的锁竞争会导致大量线程的阻塞和唤醒操作,进而带来较大的系统开销,降低了系统的并发性能。
(4)为了减少这种开销,JDK 的并发包中提供了更轻量级的锁实现,比如 ReentrantLock 和 StampedLock,它们使用了更高效的自旋、CAS 操作等技术来减少线程的阻塞和上下文切换次数,提高了并发性能。但需要注意的是,在并发度不是特别高的情况下,synchronized 关键字的性能已经足够,并且由于 JVM 对其进行了优化,使用起来更加简单和安全。
1.6.synchronized 和 volatile 有什么区别?
synchronized 关键字和 volatile 关键字是两个互补的存在,它们之间的区别如下:
- volatile 是线程同步的轻量级实现,所以 volatile 性能比 synchronized 高。
- volatile 只能用于变量而 synchronized 关键字可以修饰方法以及代码块。
- volatile 能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
2.volatile
2.1.volatile 是什么?有什么作用?
(1)synchronized 是 Java 中的一个关键字,翻译成中文是不稳定的意思,其作用如下:
- 保证变量的可见性:
- 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
- 防止 JVM 的指令重排序: volatile 关键字可以防止编译器和处理器对指令进行重排序优化。
- 在 volatile 变量的写操作之后,会插入一个内存屏障 (Memory Barrier) 指令,确保在该指令之前的所有操作都发生在该指令之前。
- 在 volatile 变量的读操作之前,也会插入一个内存屏障指令,确保在该指令之后的所有操作都发生在该指令之后。这样,可以防止指令重排序对变量的读写操作顺序造成影响。
(2)volatile 关键字其实并非是 Java 语言特有的,在 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。volatile 关键字能保证数据的可见性和有序性,但不能保证数据的原子性。synchronized 关键字则可以保证可见性和原子性。
2.2.volatile 的内存语义是什么样的?
2.2.1.内存可见性
(1)以⼀段示例代码开始:
public class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // step 1
flag = true; // step 2
}
public void reader() {
if (flag) { // step 3
System.out.println(a); // step 4
}
}
}
在这段代码里,我们使用 volatile 关键字修饰了⼀个 boolean 类型的变量 flag 。 所谓内存可见性,指的是当⼀个线程对 volatile 修饰的变量进行写操作(比如 step 2)时,JMM 会立即把该线程对应的本地内存中的共享变量的值刷新到主内存;当⼀个线程对 volatile 修饰的变量进行读操作(比如 step 3)时,JMM 会把立即该线程对应的本地内存置为无效,从主内存中读取共享变量的值。
在这⼀点上,volatile 与锁具有相同的内存效果,volatile 变量的写和锁的释放具有相同的内存语义,volatile 变量的读和锁的获取具有相同的内存语义。
(2)假设在时间线上,线程A先执行 writer 方法,线程 B 后执行 reader 方法。那必然会有下图:
而如果 flag 变量没有用 volatile 修饰,在 step 2,线程 A 的本地内存里面的变量就不会立即更新到主内存,那随后线程 B 也同样不会去主内存拿最新的值,仍然使用线程 B 本地内存缓存的变量的值 a = 0,flag = false。
2.2.2.禁止重排序
(1)在 JSR-133 之前的旧的 Java 内存模型中,是允许 volatile 变量与普通变量重排序的。 那上面的案例中,可能就会被重排序成下列时序来执行:
① 线程 A 写 volatile 变量,step 2,设置 flag 为 true;
② 线程 B 读同⼀个 volatile,step 3,读取到 flag 为 true;
③ 线程 B 读普通变量,step 4,读取到 a = 0;
④ 线程 A 修改普通变量,step 1,设置 a = 1;
可见,如果 volatile 变量与普通变量发生了重排序,虽然 volatile 变量能保证内存可见性,也可能导致普通变量读取错误。
(2)所以在旧的内存模型中,volatile 的写-读就不能与锁的释放-获取具有相同的内存语义了。为了提供⼀种比锁更轻量级的线程间的通信机制,JSR-133专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序。 编译器还好说,JVM 是怎么还能限制处理器的重排序的呢?它是通过内存屏障来实现的。
什么是内存屏障?硬件层面,内存屏障分两种:读屏障 (Load Barrier) 和写屏障 (Store Barrier) 。内存屏障有两个作用:
① 阻止屏障两侧的指令重排序;
② 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。
注意这里的缓存主要指的是 CPU 缓存,如 L1,L2 等
(3)编译器在生成字节码时,会在指令序列中插⼊内存屏障来禁止特定类型的处理器重排序。编译器选择了⼀个比较保守的 JMM 内存屏障插入策略,这样可以保证在任何处理器平台,任何程序中都能得到正确的 volatile 内存语义。这个策略是:
① 在每个 volatile 写操作前插⼊⼀个 StoreStore 屏障;
② 在每个 volatile 写操作后插⼊⼀个 StoreLoad 屏障;
③ 在每个 volatile 读操作后插⼊⼀个 LoadLoad 屏障;
④ 在每个 volatile 读操作后再插⼊⼀个 LoadStore 屏障。
大概示意图是这个样子:
再逐个解释⼀下这几个屏障(注:下述 Load 代表读操作,Store 代表写操作):
- LoadLoad 屏障:对于这样的语句 Load1; LoadLoad; Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore 屏障:对于这样的语句 Store1; StoreStore; Store2,在 Store2 及 后续写入操作执行前,保证 Store1 的写⼊操作对其它处理器可见。
- LoadStore 屏障:对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕。
- StoreLoad 屏障:对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
对于连续多个 volatile 变量读或者连续多个 volatile 变量写,编译器做了⼀定的优化来提高性能,比如:
- 第⼀个 volatile 读;
- LoadLoad 屏障;
- 第⼆个 volatile 读;
- LoadStore 屏障
(4)再介绍⼀下 volatile 与普通变量的重排序规则:
① 如果第⼀个操作是 volatile 读,那无论第⼆个操作是什么,都不能重排序;
② 如果第⼆个操作是 volatile 写,那无论第⼀个操作是什么,都不能重排序;
③ 如果第⼀个操作是 volatile 写,第⼆个操作是 volatile 读,那不能重排序。
举个例子,我们在案例中 step 1,是普通变量的写,step 2 是 volatile 变量的写,那符合第 2 个规则,这两个 steps 不能重排序。而 step 3是 volatile 变量读,step 4是普通变量读,符合第 1 个规则,同样不能重排序。
(5)但如果是下列情况:第⼀个操作是普通变量读,第⼆个操作是 volatile 变量读,那是可以重排序的:
// 声明变量
int a = 0; // 声明普通变量
volatile boolean flag = false; // 声明 volatile 变量
// 以下两个变量的读操作是可以重排序的
int i = a; // 普通变量读
boolean j = flag; // volatile变量读
内存语义:可以简单理解为 volatile,synchronize 等关键字在 JVM 中的内存方面实现原则。
2.3.volatile 有哪些用途?
(1)从 volatile 的内存语义上来看,volatile 可以保证内存可见性且禁止重排序。 在保证内存可见性这⼀点上,volatile 有着与锁相同的内存语义,所以可以作为⼀个“轻量级”的锁来使用。但由于 volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而锁可以保证整个临界区代码的执行具有原子性。所以在功能上,锁比 volatile 更强大;在性能上,volatile 更有优势。
(2)在禁止重排序这⼀点上,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;
}
}
如果这里的变量声明不使用 volatile 关键字,是可能会发生错误的。它可能会被重排序:
instance = new Singleton(); // 第10⾏
// 可以分解为以下三个步骤
1 memory=allocate(); // 分配内存,相当于 c 的 malloc
2 ctorInstanc(memory) // 初始化对象
3 s=memory // 设置 s 指向刚分配的地址
// 上述三个步骤可能会被重排序为 1-3-2,也就是:
1 memory=allocate(); // 分配内存相当于 c 的 malloc
3 s=memory //设置 s 指向刚分配的地址
2 ctorInstanc(memory) //初始化对象
而⼀旦假设发生了这样的重排序,比如线程 A 在第 10 行执行了步骤 1 和步骤 3,但是步骤 2 还没有执行完。这个时候线程 A 执行到了第 7 行,它会判定 instance 不为空, 然后直接返回了⼀个未初始化完成的 instance! 所以 JSR-133 对 volatile 做了增强后,volatile 的禁止重排序功能还是非常有用的。