Java 并发详解 ③:Synchronized

synchronized 发生异常会释放锁
不能拿 String、Integer、Long 等基础类型做锁
synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。但是同步块里的非原子操作依旧可能发生指令重排

1. notify

  • wait ():令当前资源挂起并放弃 CPU、同步资源,不再抢资源,除非唤醒
  • notify ():当其他线程的运行使得这个条件满足时,其它线程会调用 notify () 唤醒正在排队等待同步资源的下城中优先级最高者结束等待
  • notifyAll ():当其他线程的运行使得这个条件满足时,其它线程会调用 notifyAll () 唤醒正在排队等待资源的所有线程结束等待
    这三个方法只有在 synchrozied 方法或 synchrozied 代码块中才能使用

2. 使用

同步方法:锁为当前对象,即 this,所以不能用在继承方法的线程上

1
2
3
4
5
6
7
8
9
10
11
public class Bank {
private double[] accounts;
public synchronized void transfer(int from, int to, int amount) throws InterruptedException {
while (accounts[from] < amount) {
wait();
}
accounts[from] -= amount;
accounts[to] += amount;
notifyAll();
}
}

同步阻塞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Window2 implements Runnable {
// 共享数据
int ticket = 100;
// 锁(同步监视器)
Object obj = new Object();
// 所有线程必须共用同一把锁
public void run() {
while (true) {
// 若是静态方法可用当前类.class
synchronized (obj) {
if (ticket > 0) {
try {
Thread.currentThread().sleep(10);
}
catch (InterrupteException e) {
e.printStackTrace();
}
System.out.println(...);
}
}
}
}
}

3. 用户态与内核态

synchronized 加锁需要程序由用户态切换到内核态,效率低

Intel x86 架构的 CPU 来说一共有 0~3 四个特权级,0 级最高,3 级最低。

硬件上在执行每条指令时都会对指令所具有的特权级做相应的检查。相关的概念有 CPL、DPL 和 RPL。

Linux 中当程序运行在 3 级特权级上时称之运行在用户态,运行在 0 级特权级上时称之运行在内核态。

用户态通过申请外部资源(申请堆内存、读写磁盘文件。。。)切换至内核态

用户态切换到内核态的 3 种方式:

  • 系统调用
  • 中断
  • 异常

具体的切换操作: 以 open 函数调用为例
open 函数调用时,会通过中断陷入内核,从而调用 sys_open 函数。

  1. 系统调用触发 0x80 中断: 并且将系统调用号存储在 eax 寄存器中,然后陷入内核,内核开始执行中断处理程序,在系统调用表中查找系统调用号对应的系统内核函数并且调用,执行完成后又将返回值通过 eax 寄存器返回给用户空间
  2. 中断机制: 中断处理程序(内核 )
    计算机处于执行期间,系统内发生了非寻常或非预期的急需处理事件,CPU 暂时中断当前正在执行的程序而转去执行相应的事件处理程序,处理完毕后返回原来被中断处继续执行

    处理中断源的程序称为中断处理程序。中断的实现由软件和硬件综合完成,硬件部分叫做硬件装置,软件部分称为软件处理程序。

4. 锁升级

对象头 markword 记录了

锁升级步骤:

4.1. 偏向锁

偏向锁假定将来只有第一个申请锁的线程会使用锁。因此,只需要在 Markword 中 CAS 记录 当前线程指针,如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁。

以后当前线程记录的这个线程就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁

源码: fast_enter 方法中在 safepoint 的时候上锁,失败则调用 slow_enter 方法升级为自旋锁。

在明确知道会有多个线程竞争的情况下,偏向锁会涉及锁撤销,比自旋锁效率低,所以这时不会启用偏向锁

例如:JVM 启动过程会有很多线程竞争,所以默认情况启动时不打开偏向锁,过一段时间才会打开

-XX:BiasedLockingStartupDelay=4(默认 4 秒),刚开始未偏向任何一个线程,所以称为匿名偏向

4.2. 自旋锁

轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步。详情查看自旋锁

原理:
每个线程在其线程栈内部生成一个 LockRecord,拿到锁后记录在 markword 中。即演化为多个 LockRecord 使用 CAS 竞争写入 markword 的场景。

对应 slow_enter 方法,首先进入自旋,自旋锁不行则调用 inflate 方法膨胀为重量级锁。

升级:

  • 1.6 之前,有线程超过 10 次自旋(-XX:PreBlockSpin),或者自旋线程数超过 CPU 核数的一半。则升级为重量级锁。
  • 1.6 之后,加入自适应自旋 Adapative Self Spinning,由 JVM 自己控制何时升级成重量级锁。

4.3. 重量级锁

synchronized 编译为字节码后代码由 monitorentermonitorexit 包围,表示上锁和释放锁。

synchronized 加锁需要程序由用户态切换到内核态,效率低。

内核态 ObjectMonitor 对象中有 WaitSet 队列,抢锁的线程都会进入等待队列,不需要消耗 CPU 资源,由操作系统进程调度

4.4. 可重入锁

重入次数必须记录,因为加锁次数必须和解锁次数对应

  • 偏向锁 / 自旋锁:记录在线程栈中,每重入一次,LockRecord + 1。LockRecord 指向备份的 markword(displace head),里面记录了 HashCode,重入的 LockRecord 指向 null
  • 重量级锁:记录在 ObjectMonitor 一个字段上

5. 锁优化

5.1. 锁消除

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除

如下 concatString 方法:

每个 append () 方法中都有一个 synchronized 同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString () 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString () 方法之外,其他线程无法访问到它,因此可以进行消除

1
2
3
4
5
6
7
public static String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

原理: 锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除

5.2. 锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗

虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,则会把加锁的范围扩展(粗化)到整个操作序列的外部。

6. Monitor

  1. Monitor 是一种用来实现同步的工具
  2. 与每个 java 对象相关联,所有的 Java 对象是天生携带 monitor
  3. Monitor 是实现 Sychronized(内置锁)的基础
  4. Monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
ObjectMonitor() {
// 用来记录该对象被线程获取锁的次数
_count = 0;
_waiters = 0;
// 锁的重入次数
_recursions = 0;
// 指向持有 ObjectMonitor 对象的线程
_owner = NULL;
// 处于 wait 状态的线程,会被加入到 _WaitSet
_WaitSet = NULL;
_WaitSetLock = 0 ;
// 处于等待锁 block 状态的线程,会被加入到该列表
_EntryList = NULL ;
}