Java的synchronized
synchronized是Java提供的关键字,是Java并发编程中提供的同步器之一,可以对临界资源上锁。
锁的分类
根据获取的锁分类 : 获取对象锁 和 获取类锁
获取对象锁的两种用法
- 同步代码块(synchronized(this), synchronized(类实例对象)), 锁是小括号 () 中实例对象.
- 同步非静态方法(synchronized method), 锁是当前对象的实例.
获取类锁(每个类只有一个类锁)
- 同步代码块(synchronized(类.class)), 锁是小括号 () 中的类对象(Class对象)
- 同步静态方法(synchronized static method), 锁是当前对象的类对象(Class对象)
对象锁 和 类锁 总结
- 有线程访问对象的同步代码块时, 另外的线程可以访问该对象的非同步代码块
- 若锁住的是同一个对象, 一个线程在访问对象的同步代码块/同步方法时, 另一个线程访问对象的同步代码块/同步方法的线程会被阻塞。
- 若锁住的是同一个对象, 一个线程在访问对象的同步代码块时, 另一个访问对象的同步方法的线程会被阻塞, 反之亦然
- 同一个类的不同对象的对象锁互不干扰
- 类锁由于也是一种特殊的对象锁, 因此表现和上诉1, 2, 3, 4一致, 而由于同一个类只有一把类锁, 所以同一个类的不同对象使用类锁将会同步的
- 类锁 和 对象锁 互不干扰
synchronized 底层实现
每个对象都关联一个Monitor, 叫做内部锁. 低层通过 CPP
实现的, 源码文件 objectMonitor.cpp
. Monitor 可以在对象创建销毁或当线程获取对象锁时, 自动生成.
解决了每个对象都可以上锁的问题(不用再定义锁变量了).
线程抢占锁, 就是抢占Monitor
.
WaitSet
, 等待池, Monitor
内部实现的等待集合, 就让多个线程先进行自旋竞争, 失败的在等待集合中休眠。
Monitor
锁的竞争, 获取与释放。
monitorenter
和monitorexit
指令
synchronized (this) {
System.out.println("Hello");
// 再次获取锁对象, 这种情况叫做 重入
synchronized (this) {
System.out.println("World");
}
}
3: monitorenter // 进入锁
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String Hello
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_0
13: dup
14: astore_2
15: monitorenter // 进入锁
16: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
19: ldc #5 // String World
21: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
24: aload_2
25: monitorexit // 解锁
26: goto 34
29: astore_3
30: aload_2
31: monitorexit // 解锁
39: astore 4
41: aload_1
42: monitorexit // 这个指令是 编译器 生成的, 在 异常出现后会调用
43: aload 4
45: athrow
46: return
// ...
// 同步方法 方法级别的同步是隐式的 ACC_SYNCHRONIZED 标志这个方法是否是同步的
public synchronized void syncTask();
descriptor: ()V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #6 // String Hello Again
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 18: 0
line 19: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lorg/lgq/interview/thread/SyncBlockAndMethod;
monitorenter
: 获得锁, 进入临界区; 自旋失败后 -> 休眠 ->WaitSet
.
monitorleave
: 唤醒, 执行线程将释放锁.
重入现象
从互斥锁的设计上来说, 当一个线程试图操作一个由其它线程持有的对象锁的临界资源时, 将会处于阻塞状态, 但当一个线程再次请求自己持有对象的临界资源时, 不需要再次获得所, 这种情况属于重入.
synchronized 的不足之处
- 在
Java
早期版本中,synchronized
属于重量级锁, 依赖Mutext Lock
实现 - 线程之间的切换需要从用户态转换到内核态, 开销较大.
synchronized 的改进
Java 6 之后, synchronized
性能得到了很大的提升, 锁升级机制 和 一些优化操作.
- Adaptive Spinning(自适应自旋)
- Lock Eliminate(锁消除)
- Lock Coarsening(锁粗化)
- Lightweight Locking(轻量级锁)
- Biased Locking(偏向锁)
自旋锁 和 自适应自旋锁
自旋锁
- 在许多情况下, 共享数据的锁定状态持续时间较短, 切换线程不值得。
- 通过让线程执行循环等待锁的释放, 不让出
CPU
。 - 缺点 : 若锁被其它线程长时间占用, 会带来许多性能上的开销。
可以通过 PreBlcokPin
的参数来更改.
自适应自旋锁
- 自旋的次数不再固定。
- 由前一次在同一个锁上的自旋时间以及锁的拥有者(Owner)的状态决定。
锁消除
更彻底的优化: JIT
编译时, 对运行上下文进行扫描, 去除不可能存在的竞争的锁。
public class StringBufferWithoutSync {
public void add(String str1, String str2) {
// StringBuffer是线程安全,
// 由于sb只会在append方法中使用, 不可能被其他线程引用(没有 return 出去)
// 因此sb属于不可能共享的资源, JVM会自动消除内部的锁
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
public static void main(String[] args) {
StringBufferWithoutSync withoutSync = new StringBufferWithoutSync();
for (int i = 0; i < 1000; i++) {
withoutSync.add("aaa", "bbb");
}
}
}
锁粗化
另一个极端. 一般情况下, 在加同步锁的时候, 尽可能地将同步块的作用范围限制到尽量小的范围, 即只在共享数据的实际作用域中才进行同步, 这样是为了使同步的操作步骤尽可能的少. 但是如果存在一连串系列操作, 都对同一个对象反复加锁和解锁, 甚至加锁操作时出现在循环体中, 那么即使没有线程竞争, 频繁地进行互斥同步锁操作也会导致不必要的性能操作。
通过扩大加锁的范围, 避免反复加锁和解锁。
public class CoarseSync {
public static String copyString100Times(String target) {
int i = 0;
StringBuffer sb = new StringBuffer();
while (i < 100) {
// JVM 会检查到这一连串的操作都对同一个对象进行同步锁的操作, 这时 JVM 就会加锁的同步范围 粗化 到一系列操作的外部.
// 让这一连串的操作只需要加一次锁
sb.append(target);
}
return sb.toString();
}
}
线程间协作
读/计算/写 的模型
Polling:
while循环不断检查有没有新的读入. 然后进行处理.Queuing
: 实现生产者/消费者模型.
Monitor
需要考虑到线程间的协作, 因此Java语言为每个对象提供了wait
方法用于休眠, 并提供了两个用于通知的方法: notify
和notifyAll
.
Monitor
提供, Object
代理(执行), Monitor
挂在 Object
上面.
synchronized 四种状态
轻量级锁 和 偏向锁 是 JDK6
后对 synchronized
的优化。
锁膨胀方向 : 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
偏向锁
减少同一线程重复获取锁的代价, 也就是只有一个线程进入临界区.
大多数情况下, 锁不存在多线程竞争, 总是由同一线程多次获得
核心思想 : 如果一个线程获得了锁, 那么锁就进入了偏向模式, JVM会将lockObject
的对象头Mark Word
的锁标志位设为”01”, 同时会用CAS操作
把Thread#1
的线程ID记录到Mark Word
中, 当该线程再次请求锁时, 无需再做CAS操作, 即获取锁只需要检查Mark Word
的锁标记(101)是否为偏向锁以及当前线程Id是否等于Mark Word
的ThreadID即可, 这样可以节省大量有关锁申请的操作. 以后该线程在进入和退出同步块时, 无需做 CAS
的操作来加锁和解锁, 从而提高性能。
不适用于锁竞争比较激烈的多线程场合。
轻量级锁
多个线程交替进入临界区。
适用情况 : 多个线程交替执行同步块。
轻量级锁 加锁过程
- 在线程进入同步块的时候, 如果同步对象锁状态为无锁状态(锁标志位为”01”状态), JVM 首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间, 用于存储对象目前的
Mark Word
的拷贝, 官方称之为Displaced Mark Word
(加个前缀). 这时候线程堆栈与对象头的状态如下 - 拷贝对象头中
Mark Word
到锁记录中. - 拷贝成功之后, JVM将使用 CAS 操作 尝试将对象的
Mark Word
的ptr_to_lock_record
更新为指向Lock Record
的指针, 并将Lock Record
里的 owner 指针指向object mark word
. - 如果这个更新动作成功了, 就代表这个线程就拥有了该对象的锁, 并且对象
Mark Word
的锁标志位为”00”, 即表示此对象处于轻量级锁状态, 这时候线程堆栈与对象头的状态如下, 互相指向. - 如果这个更新操作失败了, 就意味着至少存在一条线程与当前线程竞争该对象的锁. JVM 首先会检查对象的
Mark Word
是否指向当前线程堆栈, 如果是, 就说明当前线程已经拥有了这个对象锁, 那就可以直接进入同步块继续执行. 否则说明这个锁对象被其它线程抢占了. - 如果出现两条以上的线程争桶一个锁的情况, 那轻量级锁就不再有效, 必须膨胀为重量级锁, 锁标志的状态值为”10”, 此时,
Mark Word
中存储的就是指向重量级锁(互斥量)的指针, 后面等待锁的线程也要进入阻塞状态.
轻量级锁 解锁过程
- 如果对象的
Mark Word
仍然指向线程的锁记录, 就通过 CAS 操作 尝试把线程中复制的Displaced Mark Word
对象替换当前的Mark Word
. - 如果替换成功, 整个同步过程就完成了.
- 如果替换失败, 说明有其它线程尝试获取锁, 那就要在释放锁的同时, 唤醒被挂起的线程.
锁的内存语义: 当线程释放锁时, Java内存模型会把该线程对应的本地内存中的变量刷新到主内存中; 而当线程获取锁时, Java内存模型就会把线程对应的本地内存置为无效, 从而使得被监视器保护的临界区代码必须从主内存中读取共享变量.
重量级锁
多个线程同时进入临界区.
若存在同一时间多个线程同时访问同一锁的情况, 就会导致轻量级锁膨胀为重量级锁, 会让其它线程阻塞.
若不膨胀为重量级锁,则所有等待轻量锁的线程只能自旋,可能会损失很多CPU时间.
偏向锁, 轻量级锁, 重量级锁 总结
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要CAS操作, 没有额外的性能消耗, 和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁的竞争, 会带来额外的锁撤销的消耗 | 只有一个线程访问同步块或者同步方法的场景 |
轻量级锁 | 竞争的线程不会阻塞, 提高了响应速度 | 若线程长时间抢不到锁, 自旋会消耗CPU性能 | 线程交替执行同步或者同步方法的场景 |
重量级锁 | 线程竞争不适用自旋, 不会消耗CPU | 线程阻塞, 响应时间缓慢, 在多线程下, 频繁地获取释放锁, 会带来巨大的性能消耗 | 追求吞吐量, 同步块或者同步方法执行时间较长的场景 |
synchronized 与 ReentrantLock
ReentrantLock
– 重入锁,是基于AQS
实现的同步器。
- 位于
java.util.concurrnet.locks
包. - 和
CountDownLatch
,FutureTask
,Semaphore
一样基于 AQS 实现. - 能够实现比
synchronized
更细粒度的控制, 如控制fairness
(不公平), 可以跨语句块. - 调用
lock()
之后, 必须调用unlock()
释放锁. - 性能未必比
synchronizd
高, 并且也是可重入的. ReentrantLock
提供中断能力.synchronized
无法被打断.
ReentrantLock
公平性的设置.
ReentrantLock fairLock = new ReentrantLock(true);
设置为公平锁.- 公平锁, 倾向于将赋予等待时间最久的线程.
- 获取锁的顺序按先后调用
lock
方法的顺序(慎用). - 非公平锁, 抢占的顺序不一定, 看运气.
synchronized
是非公平锁.
ReentrantLock
将锁对象化.
- 判断是否有线程, 或者某个特定线程, 在排队等待获取锁.
- 带超时的获取锁的尝试.
- 感知有没有成功获取到锁.
是否能将 wait/notify/notifyAll
对象化
可以. java.util.concurrent.locks.Condition
可以做到. ArrayBlockingQueue
是个例子.
总结
synchronized
是关键字,ReentrantLock
是类.ReentrantLock
可对获取锁的等待时间进行设置(超时), 避免死锁.ReentrantLock
可以获取各种锁的信息.ReentrantLock
可以灵活实现多路通知.- 机制:
synchronized
操作Mark Word
,ReentrantLock
调用Unsafe
类的park()
方法.