vlambda博客
学习文章列表

线程安全与数据同步(下)

This Monitor 和 Class Moitor的详细介绍

通过上一篇的介绍想必大家一定非常清楚,多个线程争抢同一个 monitor 的 lock 会陷入阻塞进而达到数据同步、资源同步的目的,在本章中我们将通过实例认识两个比较特别的 monitor。


this monitor

在下面的代码 ThisMonitor 中,两个方法 method1 和 method2 都被 synchronized 关键字修饰,启动了两个线程分别访问 method1 和 method2,在开始运行之前我们来思考一个问题:synchronized 关键字修饰了同一个实例对象的两个不同方法,那么与之对应的 monitor 是什么?两个 monitor 是否一致呢?
public class ThisMonitor{
public synchronized void method1(){ System.out.println(currentThread().getName() + " enter to method1"); try { TimeUnit.MINUTES.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } }
public synchronized void method2(){ System.out.println(currentThread().getName() + " enter to method2"); try { TimeUnit.MINUTES.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } }
public static void main(String[] args){ ThisMonitor thisMonitor = new ThisMonitor(); new Thread(thisMonitor::method1, "T1").start(); new Thread(thisMonitor::method2, "T2").start(); }}
带着上面的疑问,运行程序将会发现只有一个方法被调用,另外一个方法根本没有被调用,分析线程的堆栈信息,执行 jdk 自带的 jstack pid 命令,如下图所示T1 线程获取了 <0x000000078b936a38>monitor 的 lock 并且处于休眠状态,而 T2 线程企图获取 <0x000000078b936a38>monitor 的 lock 时陷入了 BLOCKED 状态,可见使用 synchronized 关键字同步类的不同实例方法,争抢的是同一个 monitor 的 lock,而与之关联的引用则是 ThisMonitor 的实例引用,为了证实我们的推论,将上面的代码稍作修改,如下所示:
public synchronized void method1(){ System.out.println(currentThread().getName() + " enter to method1"); try { TimeUnit.MINUTES.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }}public void method2(){ synchronized (this) { System.out.println(currentThread().getName() + " enter to method2"); try { TimeUnit.MINUTES.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } }}
其中,method1 保持方法同步的方式,method2 则采用了同步代码块的方式,并且使用的是 this 的 monitor,运行修改后的代码将会发现效果完全一样,在 JDK 官方文档中也有这样的描述(见https://docs.oracle.com/javase/tutorial/essential/concurrency/locksync.html):
When a thread invokes a synchronized method,it automatically acquires the intrinsic lock for that method’s object and releases it when the method returns.The lock release occurs even if the return was caused by an uncaught exception.


class monitor

同样的方式,来看下面的例子,有两个类方法(静态方法)分别使用 synchronized 对其进行同步:
public class ClassMonitor{
public static synchronized void method1(){ System.out.println(currentThread().getName() + " enter to method1"); try { TimeUnit.MINUTES.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } }
public static synchronized void method2(){ System.out.println(currentThread().getName() + " enter to method2"); try { TimeUnit.MINUTES.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
}
public static void main(String[] args){ new Thread(ClassMonitor::method1, "T1").start(); new Thread(ClassMonitor::method2, "T2").start(); }}
运行上面的例子,在同一时刻只能有一个线程访问 ClassMonitor 的静态方法,我们仍旧使用 jstack 命令分析其线程堆栈信息,如图所示。同样,我将关键的地方用红色方框标识了出来,T1 线程持有 <0x000000078b9342b0>monitor 的锁在正在休眠,而 T2 线程在试图获取 <0x000000078b9342b0>monitor 锁的时候陷入了 BLOCKED 状态,因此我们可以得出用 synchronized 同步某个类的不同静态方法争抢的也是同一个 monitor 的 lock,再仔细对比堆栈信息会发现与4.4.1节中关于 monitor 信息不一样的地方在于(a java.lang.Class for com.wangwenjun.concurrent.chapter04.ClassMonitor),由此可以推断与该 monitor 关联的引用是 ClassMonitord.class 实例。

程序死锁的原因以及如何诊断

关于死锁,在之前有过简单介绍,在本节中我们将详细介绍在什么情况下会发生死锁,以及死锁之后如何诊断。程序死锁可以类比于如图4-12所示的交通堵塞现象。


程序死锁

交叉锁可导致程序出现死锁

线程 A 持有 R1 的锁等待获取 R2 的锁,线程 B 持有 R2 的锁等待获取 R1 的锁(典型的哲学家吃面),这种情况最容易导致程序发生死锁的问题。


内存不足

当并发请求系统可用内存时,如果此时系统内存不足,则可能会出现死锁的情况。举个例子,两个线程 T1 和 T2,执行某个任务,其中 T1 已经获取了 10MB 内存,T2 获取了 20MB 内存,如果每个线程的执行单元都需要 30MB 的内存,但是剩余可用的内存刚好为 20MB,那么两个线程有可能都在等待彼此能够释放内存资源。


一问一答式的数据交换

服务端开启某个端口,等待客户端访问,客户端发送请求立即等待接收,由于某种原因服务端错过了客户端的请求,仍然在等待一问一答式的数据交换,此时服务端和客户端都在等待着双方发送数据(笔者在刚参加工作的时候就犯过这样的错误)。


数据库锁

无论是数据库表级别的锁,还是行级别的锁,比如某个线程执行 for update 语句退出了事务,其他线程访问该数据库时都将陷入死锁。


文件锁

同理,某线程获得了文件锁意外退出,其他读取该文件的线程也将会进入死锁直到系统释放文件句柄资源。


死循环引起的死锁

程序由于代码原因或者对某些异常处理不得当,进入了死循环,虽然查看线程堆栈信息不会发现任何死锁的迹象,但是程序不工作,CPU 占有率又居高不下,这种死锁一般称为系统假死,是一种最为致命也是最难排查的死锁现象,由于重现困难,进程对系统资源的使用量又达到了极限,想要做出 dump 有时候也是非常困难的。


程序死锁举例

下面我们将举例说明程序由于交叉锁引起的死锁的情况,交叉锁不仅是指自己写的代码出现了交叉的情况,如果使用某个框架或者开源库,由于对源码 API 的不熟悉,很有可能也会引起死锁,由于使用不当而出现后者死锁,排查的困难则要高于前者,所以在使用框架或者开源库的时候做到了如指掌还是很有必要的。示例代码如下:
public class DeadLock{
private final Object MUTEX_READ = new Object(); private final Object MUTEX_WRITE = new Object();
public void read() { synchronized (MUTEX_READ) { System.out.println(currentThread().getName() + " get READ lock"); synchronized (MUTEX_WRITE) { System.out.println(currentThread().getName() + " get WRITE lock"); } System.out.println(currentThread().getName() + " release WRITE lock"); } System.out.println(currentThread().getName() + " release READ lock"); }

public void write() { synchronized (MUTEX_WRITE) { System.out.println(currentThread().getName() + " get WRITE lock"); synchronized (MUTEX_READ) { System.out.println(currentThread().getName() + " get READ lock"); } System.out.println(currentThread().getName() + " release READ lock"); } System.out.println(currentThread().getName() + " release WRITE lock"); }
public static void main(String[] args) { final DeadLock deadLock = new DeadLock(); new Thread(() -> { while (true) { deadLock.read(); } }, "READ-THREAD").start();
new Thread(() -> { while (true) { deadLock.write(); } }, "WRITE-THREAD").start(); }}
上面的程序再明显不过了,一眼就可以看出程序有死锁的风险,如果使用一些开源库,API 的调用层次比较深,那么看代码是不容易发现死锁风险的,比如 JDK 中的 HashMap,文档很明显地指出了该数据结构不是线程安全的类,如果在多线程同时写操作的情况下不对其进行同步化封装,则很容易出现死循环引起的死锁,程序运行一段时间后 CPU 等资源高居不下,各种诊断工具很难派上用场,因为死锁引起的进程往往会榨干 CPU 等几乎所有资源,诊断工具由于缺少资源一时间也很难启动,比如下面的例子:
public class HashMapDeadLock{ private final HashMap<String, String> map = new HashMap<>();
public void add(String key, String value) { this.map.put(key, value); }
public static void main(String[] args) {
final HashMapDeadLock hmdl = new HashMapDeadLock(); for (int x = 0; x < 2; x++) new Thread(() -> { for (int i = 1; i < Integer.MAX_VALUE; i++) { hmdl.add(String.valueOf(i), String.valueOf(i)); } }).start(); }}
HashMap 不具备线程安全的能力,如果想要使用线程安全的 map 结构请使用 ConcurrentHashMap 或者使用 Collections.synchronizedMap 来代替。


死锁诊断

大致知道了引起死锁的几种原因之后,也看了两个具体的实例,本节中,我们将借助诊断工具对其进行诊断。


交叉锁引起的死锁

运行 DeadLock 代码,程序将陷入死锁,打开 jstack 工具或者 jconsole 工具,Jstack-l PID 会直接发现死锁的信息,示例代码如下:
Found one Java-level deadlock:============================="WRITE-THREAD": waiting to lock monitor 0x0000000009d1b0f8 (object 0x000000078b936800, a java.lang.Object), which is held by "READ-THREAD""READ-THREAD": waiting to lock monitor 0x0000000009d1b048 (object 0x000000078b936810, a java.lang.Object), which is held by "WRITE-THREAD"
Java stack information for the threads listed above:==================================================="WRITE-THREAD": at com.wangwenjun.concurrent.chapter04.DeadLock.write(DeadLock.java:33) - waiting to lock <0x000000078b936800> (a java.lang.Object) - locked <0x000000078b936810> (a java.lang.Object) at com.wangwenjun.concurrent.chapter04.DeadLock.lambda$main$1(DeadLock.java:55) at com.wangwenjun.concurrent.chapter04.DeadLock$$Lambda$2/791452441.run(Unknown Source) at java.lang.Thread.run(Thread.java:745)"READ-THREAD": at com.wangwenjun.concurrent.chapter04.DeadLock.read(DeadLock.java:18) - waiting to lock <0x000000078b936810> (a java.lang.Object) - locked <0x000000078b936800> (a java.lang.Object) at com.wangwenjun.concurrent.chapter04.DeadLock.lambda$main$0(DeadLock.java:47) at com.wangwenjun.concurrent.chapter04.DeadLock$$Lambda$1/424058530.run(Unknown Source) at java.lang.Thread.run(Thread.java:745)
Found 1 deadlock.
一般交叉锁引起的死锁线程都会进入 BLOCKED 状态,CPU 资源占用不高,很容易借助工具来发现。


死循环引起的死锁(假死)

运行 HashMapDeadLock 程序,也可以使用 jstack、jconsole、jvisualvm 工具或者 jProfiler(收费的)工具进行诊断,但是不会给出很明显的提示,因为工作的线程并未 BLOCKED,而是始终处于 RUNNABLE 状态,CPU 使用率高居不下,甚至都不能够正常运行你的命令。
如图所示的是使用 jprofile 工具抓出来的方法线程运行状态,可以发现某个线程在执行 hashmap 的 put 方法时陷入了死循环,而且 CPU 占用率非常高,一个很普通的方法调用导致了接近100毫秒的耗时很显然是不正常的。
严格意义上来说死循环会导致程序假死,算不上真正的死锁,但是某个线程对 CPU 消耗过多,导致其他线程等待 CPU,内存等资源也会陷入死锁等待。