《并发编程》那篇讲了因为线程内存共享会导致并发问题,要解决并发问题可以保证对共享资源修改的互斥性和对共享资源修改后的内存可见性。
如果要求更严格,需要保证任何对共享资源访问的互斥性。
今天介绍的Java关键字 synchronized 就是一种保证线程安全的同步机制,synchronized 的工作原理如下:
synchronized 会给被修饰的代码快加一把锁,当一个线程执行到这部分代码时,会先获取该锁,获取成功才能继续执行,否则只能等待这个锁被释放。
lock;
{
//do something
}
unlock;
一个线程获得同步锁后,其他线程必须等待,直到该线程释放锁。这样可以防止多个线程同时修改同一个共享资源,确保互斥性。
基本使用
关键字 synchronized 的使用方法很简单,随便找一个对象作为锁就行。
public class SynchronizedDemo {
private final Object lock = new Object(); // 锁
private int count = 0; // 共享资源
void increment(){
synchronized (lock){
count++;
}
}
}
上述 synchronized (lock) 表示线程进入这个代码块时,必须首先获得 lock 对象的锁。只有持有该锁的线程才能执行 count++ 操作。
当然这个 lock 对象是随便构造的,也就是任意对象都能当做锁,this对象,Class对象都行。
public class SynchronizedDemo {
private final Object lock = new Object(); // 锁
private int count = 0; // 共享资源
void increment(){
synchronized (lock){
count++;
}
}
synchronized void decrement(){
count--;
}
int getCount(){
synchronized (SynchronizedDemo.class){
return count;
}
}
}
decrement() 方法使用synchronized 关键字修饰,就是使用 this 对象作为实例锁,而 getCount() 方法则是使用 SynchronizedDemo 的 Class 实例作为类锁。
那分析一下,上述对共享资源的修改和访问都使用对象锁进行保护了,那多线程调用这些方法会有并发问题吗?
increment() 方法使用的是自定义的 lock 对象,decrement() 方法使用的是实例锁(this),getCount() 方法使用的是类锁(SynchronizedDemo.class),三个方法三把锁。
也就是一份共享资源(count)有三种访问途径,这三条路各有一把锁锁定,
完全有可能三个线程各获取一把对应的锁,同时访问共享资源 count。
所以使用关键字 synchronized 的最佳实践就是使用一把锁,保护共享资源:
⨳ 实例锁(this) :适用于 SynchronizedDemo 是单例的情况,它将锁定整个实例,相同实例不同方法之间会共享这把锁。
⨳ 类锁(SynchronizedDemo.class):适用于 SynchronizedDemo 是多例的情况,它将锁定整个类,不同实例之间会共享这把锁。
其实不管是实例锁(this) 还是 类锁(SynchronizedDemo.class),本质都是将某一对象作为锁,都是对象锁。
那一个普通的对象怎么能作为锁呢?底层细节又是什么呢?
锁的本质
轻量级锁
对象头是每个 Java 对象的内存结构的一部分,包含了与对象相关的元数据信息。在 HotSpot JVM 中,Java 对象的对象头通常分为两部分:
⨳ Mark Word:用于存储对象的状态和一些元数据,包括哈希码、GC 信息、锁信息等。
⨳ Class Pointer:指向该对象的类元数据(Class Metadata),用于标识对象属于哪个类。
Mark Word 是对象头中与锁相关的部分。
- 线程检查对象头中的 Mark Word:当线程(比如线程 A)尝试获取锁时,它首先检查对象的对象头中的 Mark Word。
Mark Word存储了与锁状态相关的元数据信息。初始情况下,如果对象是无锁状态(Unlocked) ,Mark Word中存储的是默认值(例如对象的哈希码、GC信息等)。这时,Mark Word没有与锁相关的信息。 - 创建锁记录并备份 Mark Word:当线程 A 确认对象处于无锁状态后,线程 A 会在它的栈帧中创建一个锁记录(Lock Record),锁记录存储与当前线程持有的锁相关的状态信息。 然后线程 A 会将对象头中的原始
Mark Word复制到它的锁记录中。 - 尝试使用 CAS 操作修改 Mark Word:接下来,线程 A 通过 CAS 操作,尝试将对象头中的
Mark Word更新为指向线程栈中锁记录的指针。如果线程 A 成功地将Mark Word更新为指向它的锁记录的指针,表示线程 A 已成功获取了锁,并且对象进入了轻量级锁状态。 - Mark Word 恢复:当线程退出
synchronized代码块时,JVM 会从线程栈中的锁记录中恢复对象头的Mark Word,将锁对象的状态恢复到解锁状态(即无锁状态)。
CAS(Compare-And-Swap)是一种硬件支持的原子操作,用来保证线程在并发访问共享资源时的安全性。
使用查看对象的内存布局的工具 JOL (Java Object Layout) 工具,就可以看到对象头中的详细信息。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
▪ 无线程竞争
import org.openjdk.jol.info.ClassLayout;
public class ThinLockDemo {
public static void main(String[] args) {
Object lock = new Object();
System.out.println("====加锁前====");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock){
System.out.println("====加锁后====");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}
}
普通对象 lock 作为锁之前的内存布局如下:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00000e80
12 4 (object alignment gap)
Instance size: 16 bytes
普通对象 lock 作为锁之后的内存布局如下:
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x000000da729ff570 (thin lock: 0x000000da729ff570)
8 4 (object header: class) 0x00000e80
12 4 (object alignment gap)
Instance size: 16 bytes
注意看,对象头中的 Mark Word 从 0x0000000000000001 变成了 0x000000da729ff570,很自然的就能想到,这个 0x000000da729ff570 就是指向当前线程的锁记录(Lock Record)的指针。
那为什么对象头中的 Mark Word 最初是 0x0000000000000001 呢?怎么感觉没有对象的哈希码、GC信息呀。
这时因为JVM 使用 延迟计算哈希码 的策略。对象的哈希码只有在程序显式调用 Object.hashCode() 时,才会在 Mark Word 中存储该对象的哈希值。
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000005b6f741201 (hash: 0x5b6f7412; age: 0)
8 4 (object header: class) 0x00000e80
12 4 (object alignment gap)
Instance size: 16 bytes
偏向锁
如果一个线程多次获取同一把锁,并且没有其他线程竞争这把锁,JVM 就会让这把锁“偏向”该线程。之后的加锁和解锁操作都不需要进行同步操作,也不会引发CAS(Compare-And-Swap)操作,从而提高性能。
当一个线程第一次尝试获取偏向锁时,JVM 会将对象头中的 Mark Word 修改为指向这个线程,并将标志位设置为偏向锁模式。之后,只要该线程再次尝试获取这把锁,JVM 就无需做任何加锁操作,因为锁已经“偏向”这个线程。
也就是说,偏向锁是比轻量级锁更轻量。那为什么 ThinLockDemo 在加锁后会是轻量级锁,而不是偏向锁呢?
JVM 默认开启偏向锁,但偏向锁有延迟启动机制。JVM 启动时,偏向锁不会立即生效,而是在 JVM 启动后的几秒钟(默认 4 秒)之后才启用。如果在这个延迟期间执行了加锁操作,锁不会偏向于任何线程,而是直接进入轻量级锁模式。
但在 JDK15 中,偏向锁被默认关闭。在 JDK18 中,更被标记为废弃,并不再允许通过命令行手动开启。
OpenJDK 开发团队在 JEP 374 中解释:
偏向锁定的代价是在发生锁争用时需要执行昂贵的撤销操作。因此,受益于它的程序只是那些无竞争同步操作的程序。偏向锁的高效是假定在执行简单的锁检查加上偶尔昂贵的撤销成本,仍然低于执行 CAS 指令的成本。但 HotSpot 已经发生了很大的变化,原子指令成本的变化也改变了保持该关系所需的无竞争操作的数量。
也就是说,随着硬件和 JVM 的改进,轻量锁的 CAS 指令的效率得到了提升,偏向锁已经没这么香了,而且OpenJDK 开发者统计到同步操作在程序实际运行中消耗的资源较少,即使偏向锁有一定提升,但对总体性能影响不大,食之无味,不如废弃。
重量级锁
轻量级锁是没有线程竞争,一次CAS操作就成功的情况,如果多个线程竞争同一个锁,轻量级锁将升级为重量级锁,在重量级锁状态下,对象头的 Mark Word 不再指向线程栈中的锁记录,而是指向一个 ObjectMonitor 对象,毕竟得给竞争失败的线程一个记录的地方。
import org.openjdk.jol.info.ClassLayout;
public class HeavyLockDemo {
synchronized void work() {
try {
System.out.println("Thread " + Thread.currentThread().getName() + " is working...");
Thread.sleep(5*1000); // 模拟工作负载
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
HeavyLockDemo heavyLockDemo = new HeavyLockDemo();
System.out.println("====无锁状态====");
System.out.println(ClassLayout.parseInstance(heavyLockDemo).toPrintable());
new Thread(()->heavyLockDemo.work()).start();
Thread.sleep(1000);
System.out.println("====轻量锁====");
System.out.println(ClassLayout.parseInstance(heavyLockDemo).toPrintable());
Thread.sleep(2000);
new Thread(()->heavyLockDemo.work()).start();
System.out.println("====重量锁====");
System.out.println(ClassLayout.parseInstance(heavyLockDemo).toPrintable());
}
}
输出结果如下:
com.cango.thread.state.HeavyLockDemo object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x01003000
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
Thread Thread-0 is working...
====轻量锁====
com.cango.thread.state.HeavyLockDemo object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x00000065298ff070 (thin lock: 0x00000065298ff070)
8 4 (object header: class) 0x01003000
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
====重量锁====
com.cango.thread.state.HeavyLockDemo object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000018b656024e2 (fat lock: 0x0000018b656024e2)
8 4 (object header: class) 0x01003000
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
Thread Thread-1 is working...
从上述结果可知,当一个线程持有锁,另一个线程想要竞争锁时,会进入重量级锁,重量级锁对应JDK源码中的 ObjectMonitor,涉及以下关键字段:
⨳ _owner:存储当前持有锁的线程的引用
⨳ _entryList:等待获取锁的线程列表
竞争失败的线程对存储在 _entryList 队列中,直到持有锁的线程释放锁。
其实ObjectMonitor 还有一个等待队列 _waitSet,用于存储调用
wait()后进入等待状态的线程。
可重入锁
ObjectMonitor 内部还有一个 _recursions 属性,用于记录的是同一个线程递归进入同步块的次数。
例如,第一次获取锁时 _recursions = 1,第二次重入时 _recursions = 2,依次类推。每退出一次同步块或方法,_recursions 递减,直到完全退出同步块时 _recursions 归零。
也就是说 关键字 synchronized 提供的锁是可重入锁,如果一个线程已经持有了某个锁,它可以在同一个线程上下文中再次获取该锁,而不会被阻塞。
public synchronized void methodA() {
methodB();
}
public synchronized void methodB() {
// do something
}
在这个例子中,methodA() 和 methodB() 都是使用同一个锁。当线程调用 methodA() 时,它首先获取 this 锁,然后进入 methodB(),在 methodB() 中同样尝试获取 this 锁。
如果只有一个线程,那就不会升级成重量锁,那重入次数会存储在线程栈中的 锁记录(Lock Record) 里,这个了解即可。
锁相关方法
我们已经知道锁就是普通的对象,那锁的方法就是对象的方法,那什么方法是所有对象都有的呢?Java 的所有类都直接或间接继承自 java.lang.Object 类,因此,Object 类提供的部分方法就是与锁相关的方法。
⨳ void wait():使当前线程进入等待状态,释放锁,直到其他线程调用 notify() 或 notifyAll() 方法唤醒它。
⨳ void wait(long timeout):使当前线程进入等待状态,释放锁,直到其他线程调用 notify()、notifyAll() 方法唤醒它,或者等待时间超过 timeout 指定的毫秒数。
⨳ void wait(long timeout, int nanos):与 wait(long timeout) 类似,但可以额外指定等待时间的纳秒部分。
⨳ void notify():唤醒在等待当前对象锁的某个线程,如果有多个线程在等待,它只会选择其中一个线程唤醒,通常是最先等待的线程,遵循FIFO顺序。
⨳ void notifyAll():唤醒在等待当前对象锁的所有线程。
其实这些方法都与 ObjectMonitor 的等待队列 _waitSet 有关:
⨳ 当一个线程调用 wait() 方法时,当前线程会释放持有的对象锁,并进入 ObjectMonitor 的 _waitSet 队列,等待被其他线程唤醒。
⨳ 当一个线程调用 notify() 方法时,ObjectMonitor 会从 _waitSet 中选择一个等待的线程进行唤醒,唤醒的线程从 _waitSet 队列移除,并进入 entryList(锁竞争队列)中,等待重新获取对象的锁。
⨳ notifyAll() 方法会唤醒 _waitSet 中的所有线程,将它们从等待队列移出,并将它们转移到 ObjectMonitor 的 entryList 队列中等待重新获取锁。
需要注意的是无论是 notify() 还是 notifyAll(),被唤醒的线程会进入entryList 队列后,需要等到调用 notify 的线程释放锁后,再一起进行锁竞争(并不是FIFO),竞争完成后,只有一个线程能够成功获取锁,其余线程会继续在entryList 队列中等待锁的释放。
下一篇《生产者-消费者模式》将会详细讲解 wait 和 notify 的用法,敬请期待。
总结
上面讲了这么多,核心就是普通对象可以作为互斥锁而存在,普通对象的对象头中的Mark Word部分可以存储线程相关信息,这样线程在进入关键字 synchronized 修饰代码(临界区)时就可以查看这个锁有没有被别的线程占用,如果占用就进入 entryList 队列等待锁的释放,从而实现了 对共享资源访问的互斥性。
那对共享资源修改后的内存可见性怎么保证呢?
Java 内存模型(Java Memory Model, JMM)规定,对一个锁的解锁操作 Happens-Before 于后续对该锁的加锁操作。这意味着,当一个线程释放锁时,它在临界区内的所有修改对接下来获取该锁的线程都是可见的。