避免死锁
死锁问题是多线程特有的问题。在死锁时,线程间相互等待资源,而不释放自身的资源,导致无穷尽的等待,其结果是系统任务永远无法执行完成。 死锁出现的条件
互斥条件:一个资源每次只能被一个进程使用 请求与保持条件:一个进程因为请求资源而阻塞时,对已获得的资源保持不放 不剥夺条件:进程已经获得资源,在未使用完之前,不能强行剥夺 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系
简单示例
public class DeadLock {
public static String obj1 = "obj1";
public static String obj2 = "obj2";
public static void main(String[] args) {
Thread a = new Thread(new Lock1(), "Thread_Lock_1");
Thread b = new Thread(new Lock2(), "Thread_Lock_2");
a.start();
b.start();
}
static class Lock1 implements Runnable {
@Override
public void run() {
try {
System.out.println("Lock1 running");
while (true) {
synchronized (DeadLock.obj1) {
System.out.println("Lock1 lock obj1");
//获取obj1后先等一会儿,让Lock2有足够的时间锁住obj2
Thread.sleep(3000);
synchronized (DeadLock.obj2) {
System.out.println("Lock1 lock obj2");
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
static class Lock2 implements Runnable {
@Override
public void run() {
try {
System.out.println("Lock2 running");
while (true) {
synchronized (DeadLock.obj2) {
System.out.println("Lock2 lock obj2");
Thread.sleep(3000);
synchronized (DeadLock.obj1) {
System.out.println("Lock2 lock obj1");
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
dump 输出:
"Thread_Lock_2":
at com.learning.thread.optimize.DeadLock$Lock2.run(DeadLock.java:54)
- waiting to lock <0x0000000780954668> (a java.lang.String) // 锁定 0x0000000780954668
- locked <0x0000000780954698> (a java.lang.String) // 等待 0x0000000780954698
at java.lang.Thread.run(Thread.java:745)
"Thread_Lock_1":
at com.learning.thread.optimize.DeadLock$Lock1.run(DeadLock.java:34)
- waiting to lock <0x0000000780954698> (a java.lang.String) // 锁定 0x0000000780954698
- locked <0x0000000780954668> (a java.lang.String) // 等待 0x0000000780954668
at java.lang.Thread.run(Thread.java:745)
线程dump
检查死锁:可以使用jstack
命令,详细见JVM命令相关博文
jps // 查询进程号
jstack + 进程号
减少锁持有时间
减少对某个锁的占用时间,减少线程间互斥的可能 简单例子
public class ReduceLockTime {
/**
* 假设一下方法中只有 secondMethod(); 是需要同步的,而
* oneMethod 与 threadMethod 方法不需要做同步控制且比较耗资源
*/
public synchronized void syncMethod() {
oneMethod();
secondMethod();
threadMethod();
}
/**
* 明显减少了线程持有锁的时间,提高了系统的吞吐量
*/
public void optimizeSyncMethod() {
oneMethod();
synchronized (this) {
secondMethod();
}
threadMethod();
}
}
JDK经典的减少锁时间的例子:正则表达式的 Pattern 类
public Matcher matcher(CharSequence input) {
if (!compiled) {
synchronized(this) {// 没有直接锁定方法,而是锁定方法中的一部分
if (!compiled)
compile();
}
}
Matcher m = new Matcher(this, input);
return m;
}
减小锁粒度:缩小锁定对象的范围
经典的实现案例是JDK7实现的 ConcurrentHashMap
,分割数据结构,锁定更小粒度的 segment
具体分析ConcurrentHashMap
实现,详见并发数据结构相关博文
一个典型的 HashMap,如果 get
和 add
进行同步,如果锁对象为整个 HashMap
,那么没有两个线程是可以真正的并发。 ConcurrentHashMap
,采用拆分锁对象的方式提高吞吐量。其将整个 HashMap
拆分成若干个段segment
,每段都是一个子的HashMap
如果需要在 ConcurrentHashMap
中增加一个新的数据,并不是将整个HashMap
加锁,而是先根据hashcode
得到该数据应该被存到哪个段里面,然后对该段加锁。多个线程同时进行put
操作,只要被加入的数据不是同一个段中,可以真正的并行
问题:系统需要全局锁时,其消耗的资源比较大。如 ConcurrentHashMap
的size()
方法,需要每段加锁加以统计。如果不加锁统计就只能是一个不精确的值(JDK8实现的时候就牺牲了精度,提升了效率)
锁分离
读写锁:读锁、写锁的分离
详细的读写锁分析,可以参考读写锁相关博文 读写锁适合读多写少的场合,原因在于读与读之间是无锁的
锁分离:独占锁也可以分离
读写锁思想的延伸就是锁分离。读写锁根据读写操作功能上的不同,进行了有效的锁分离 JDK经典锁分离实现 LinkedBlockingQueue
,具体源码分析见并发数据结构相关博文 LinkedBlockingQueue
实现简单如下:
链表实现。移除(take
) 和 存放(put
)两个操作分别作用于队列的前端和尾端。这和队列的先进先出是一致的(优先级除外) 如果使用独占锁,那么移除(take
) 和 存放(put
) 操作不可能真正的并发,如 ArrayBlockQueue
所以 JDK 的实现是两把不同的锁分离了移除(take
)和存放(put
)操作。即 take
与take
之间一把锁,put
与put
之间一把锁
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
重入锁与内部锁
使用上:
内部锁 synchronized
使用简单,易于维护,自动释放 重入锁 ReentrantLock
使用较为复杂,必须在 finally
块中手动释放锁
性能上:
内部锁 synchronized
以前性能差一点,jvm 优化后,性能差不多。见JDK8源码ConcurrentHashMap
使用CAS+ synchronized
替换了原来的segment
+ Lock
实现
功能上:
重入锁 ReentrantLock
功能强大,读写锁、锁的等待时间(tryLock(long time, TimeUnit unit))、支持锁中断(lockInterruptibly)、快速锁轮询等 内部锁 synchronized
相对功能比较单一
在不是很复杂的功能前提下,建议使用内部锁 synchronized
锁粗化
通常情况下,锁的粒度需要细化,锁持有的时间尽量短 但是如一连串连续的对同一个锁不断的进行请求与释放,需要所有的锁操作整合成对锁的一次请求,从而减少多锁的请求同步次数
// 多次锁的请求
public void syncMethod() {
synchronized (this) {
oneMethod();
}
secondMethod();
synchronized (this) {
threadMethod();
}
}
// 整合成一次锁请求
public void optimizeSyncMethod() {
synchronized (this) {
oneMethod();
secondMethod();
threadMethod();
}
}
一个典型的反面例子是:在循环内部调用锁请求
public void badMethod() {
for (int i = 0; i < 10; i++) {
synchronized (this) {
// do something
}
}
}
自旋锁
在多线程并发时,频繁的挂起和恢复线程的操作会给系统带来极大的压力。
当共享的资源花费较小的CPU时间,那么锁等待只需要很短的时间 线程挂起和恢复的时间 > 锁等待时间(这是一种理论的可能)
减少线程挂起,JVM引入了自旋锁
自旋锁可以使得线程在没有取得锁时,不被挂起,转而去执行一个空循环(就是自旋) 在若干个空循环后,线程如果获得了锁,则继续执行;若线程依然不能获取锁,才会被挂起
使用自旋锁,线程被挂起的几率相对减少,线程执行的连贯性相对加强。
对与锁竞争不是很激烈,锁占用时间很短的并发线程,有一定的积极意义 对于锁竞争激烈,锁占用时间长的并发程序,自旋锁在等待后,依然还是有可能会被挂起。这样就白白浪费了系统资源
JVM 自旋锁命令
开启:-XX:+UseSpinning
设置等待自旋锁等待次数:-XX:PreBlockSpin
锁消除与逃逸分析
JDK有些API,如StringBuffer
、Vector
会被大面积在非并发的环境中使用,这样对于其内部的同步方法,其实是不必要的 JVM 虚拟机在运行时,可以基于逃逸分析技术,捕获这些不可能存在竞争却又申请锁的代码段,并消除这些不必要的锁,从而提高系统性能 JVM 开启
-XX:DoEscapeAnalysis
:开启逃逸分析-XX:+EliminateLocks
:开启锁消除(必须工作在-server
模式)
锁偏向
核心思想:如果程序没有竞争,则取消之前已经取得锁的线程同步操作
若一个锁被线程获取后,便进入偏向模式,当线程再次请求这个锁时,无需进行相关的同步操作,从而节省了操作时间 如果在此之间有其他线程进行了锁请求,则锁退出偏向模式。
JVM 偏向命令
-XX:+UseBiasedLocking
:开启命令-XX:-UseBiasedLocking
:禁用命令
注意:偏向锁在竞争激烈的场合没有优化效果,而且还会有损系统性能
大量的锁竞争会导致持有锁的线程不停的切换,锁也很难一致保持在偏向模式 在这种场合,需要禁用锁偏向
参考
源码地址