JDK多线程基础(16):锁的性能与优化

避免死锁

  1. 死锁问题是多线程特有的问题。在死锁时,线程间相互等待资源,而不释放自身的资源,导致无穷尽的等待,其结果是系统任务永远无法执行完成。
  2. 死锁出现的条件
  • 互斥条件:一个资源每次只能被一个进程使用
  • 请求与保持条件:一个进程因为请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:进程已经获得资源,在未使用完之前,不能强行剥夺
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系
  1. 简单示例
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)
	
  1. 线程dump检查死锁:可以使用jstack命令,详细见JVM命令相关博文
jps // 查询进程号
jstack + 进程号

减少锁持有时间

  1. 减少对某个锁的占用时间,减少线程间互斥的可能
  2. 简单例子
public class ReduceLockTime {

    /**
     * 假设一下方法中只有 secondMethod(); 是需要同步的,而
     * oneMethod 与 threadMethod 方法不需要做同步控制且比较耗资源
     */
    public synchronized void syncMethod() {
        oneMethod();
        secondMethod();
        threadMethod();
    }

    /**
     * 明显减少了线程持有锁的时间,提高了系统的吞吐量
     */
    public void optimizeSyncMethod() {
        oneMethod();
        synchronized (this) {
            secondMethod();
        }
        threadMethod();
    }
}

  1. JDK经典的减少锁时间的例子:正则表达式的 Pattern 类
    public Matcher matcher(CharSequence input) {
        if (!compiled) {
            synchronized(this) {// 没有直接锁定方法,而是锁定方法中的一部分
                if (!compiled)
                    compile();
            }
        }
        Matcher m = new Matcher(this, input);
        return m;
    }

减小锁粒度:缩小锁定对象的范围

  1. 经典的实现案例是JDK7实现的 ConcurrentHashMap,分割数据结构,锁定更小粒度的 segment
  2. 具体分析ConcurrentHashMap实现,详见并发数据结构相关博文
  • 一个典型的 HashMap,如果 getadd 进行同步,如果锁对象为整个 HashMap,那么没有两个线程是可以真正的并发。
  • ConcurrentHashMap,采用拆分锁对象的方式提高吞吐量。其将整个 HashMap 拆分成若干个段segment,每段都是一个子的HashMap
  • 如果需要在 ConcurrentHashMap 中增加一个新的数据,并不是将整个HashMap加锁,而是先根据hashcode得到该数据应该被存到哪个段里面,然后对该段加锁。多个线程同时进行put操作,只要被加入的数据不是同一个段中,可以真正的并行
  1. 问题:系统需要全局锁时,其消耗的资源比较大。如 ConcurrentHashMapsize()方法,需要每段加锁加以统计。如果不加锁统计就只能是一个不精确的值(JDK8实现的时候就牺牲了精度,提升了效率)

锁分离

读写锁:读锁、写锁的分离

  1. 详细的读写锁分析,可以参考读写锁相关博文
  2. 读写锁适合读多写少的场合,原因在于读与读之间是无锁的
读锁写锁
读锁可访问不可访问
写锁不可访问不可访问

锁分离:独占锁也可以分离

  1. 读写锁思想的延伸就是锁分离。读写锁根据读写操作功能上的不同,进行了有效的锁分离
  2. JDK经典锁分离实现 LinkedBlockingQueue,具体源码分析见并发数据结构相关博文
  3. LinkedBlockingQueue实现简单如下:
  • 链表实现。移除(take) 和 存放(put)两个操作分别作用于队列的前端和尾端。这和队列的先进先出是一致的(优先级除外)
  • 如果使用独占锁,那么移除(take) 和 存放(put) 操作不可能真正的并发,如 ArrayBlockQueue
  • 所以 JDK 的实现是两把不同的锁分离了移除(take)和存放(put)操作。即 taketake之间一把锁,putput之间一把锁

/** 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();

重入锁与内部锁

  1. 使用上:
  • 内部锁 synchronized 使用简单,易于维护,自动释放
  • 重入锁 ReentrantLock 使用较为复杂,必须在 finally 块中手动释放锁
  1. 性能上:
  • 内部锁 synchronized 以前性能差一点,jvm 优化后,性能差不多。见JDK8源码ConcurrentHashMap 使用CAS+ synchronized 替换了原来的segment + Lock 实现
  1. 功能上:
  • 重入锁 ReentrantLock 功能强大,读写锁、锁的等待时间(tryLock(long time, TimeUnit unit))、支持锁中断(lockInterruptibly)、快速锁轮询等
  • 内部锁 synchronized 相对功能比较单一
  1. 在不是很复杂的功能前提下,建议使用内部锁 synchronized

锁粗化

  1. 通常情况下,锁的粒度需要细化,锁持有的时间尽量短
  2. 但是如一连串连续的对同一个锁不断的进行请求与释放,需要所有的锁操作整合成对锁的一次请求,从而减少多锁的请求同步次数
// 多次锁的请求
public void syncMethod() {
    synchronized (this) {
        oneMethod();
    }

    secondMethod();

    synchronized (this) {
        threadMethod();
    }
}

// 整合成一次锁请求
public void optimizeSyncMethod() {
    synchronized (this) {
        oneMethod();
        secondMethod();
        threadMethod();
    }
}
  1. 一个典型的反面例子是:在循环内部调用锁请求
public void badMethod() {
    for (int i = 0; i < 10; i++) {
        synchronized (this) {
            // do something
        }
    }
}

自旋锁

  1. 在多线程并发时,频繁的挂起和恢复线程的操作会给系统带来极大的压力。
  • 当共享的资源花费较小的CPU时间,那么锁等待只需要很短的时间
  • 线程挂起和恢复的时间 > 锁等待时间(这是一种理论的可能)
  1. 减少线程挂起,JVM引入了自旋锁
  • 自旋锁可以使得线程在没有取得锁时,不被挂起,转而去执行一个空循环(就是自旋)
  • 在若干个空循环后,线程如果获得了锁,则继续执行;若线程依然不能获取锁,才会被挂起
  1. 使用自旋锁,线程被挂起的几率相对减少,线程执行的连贯性相对加强。
  • 对与锁竞争不是很激烈,锁占用时间很短的并发线程,有一定的积极意义
  • 对于锁竞争激烈,锁占用时间长的并发程序,自旋锁在等待后,依然还是有可能会被挂起。这样就白白浪费了系统资源
  1. JVM 自旋锁命令
  • 开启:-XX:+UseSpinning
  • 设置等待自旋锁等待次数:-XX:PreBlockSpin

锁消除与逃逸分析

  1. JDK有些API,如StringBufferVector 会被大面积在非并发的环境中使用,这样对于其内部的同步方法,其实是不必要的
  2. JVM 虚拟机在运行时,可以基于逃逸分析技术,捕获这些不可能存在竞争却又申请锁的代码段,并消除这些不必要的锁,从而提高系统性能
  3. JVM 开启
  • -XX:DoEscapeAnalysis:开启逃逸分析
  • -XX:+EliminateLocks:开启锁消除(必须工作在-server模式)

锁偏向

  1. 核心思想:如果程序没有竞争,则取消之前已经取得锁的线程同步操作
  • 若一个锁被线程获取后,便进入偏向模式,当线程再次请求这个锁时,无需进行相关的同步操作,从而节省了操作时间
  • 如果在此之间有其他线程进行了锁请求,则锁退出偏向模式。
  1. JVM 偏向命令
  • -XX:+UseBiasedLocking:开启命令
  • -XX:-UseBiasedLocking:禁用命令
  1. 注意:偏向锁在竞争激烈的场合没有优化效果,而且还会有损系统性能
  • 大量的锁竞争会导致持有锁的线程不停的切换,锁也很难一致保持在偏向模式
  • 在这种场合,需要禁用锁偏向

参考

  1. 源码地址

Fork me on Gitee

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值