zoukankan      html  css  js  c++  java
  • JVM学习笔记(六):锁优化与CAS

    1 来源

    • 来源:《Java虚拟机 JVM故障诊断与性能优化》——葛一鸣
    • 章节:第八章

    本文是第八章的一些笔记整理。

    2 概述

    本文主要讲述了JVM在运行层面和代码层面的锁优化策略,最后介绍了实现无锁的其中一种方法CAS

    3 对象头

    JVM中每个对象都有一个对象头,用于保存对象的系统信息,64bit JVM的对象头结构如下图所示:

    在这里插入图片描述

    其中:

    • Mark Word64bit组成,一个功能数据区,可以存放对象的哈希、对象年龄、锁的指针等信息
    • KClass Word在没有开启指针压缩的情况下,64bit组成,但是64bit JVM会默认开启指针压缩(+UseCompressedOops),所以会压缩到32bit

    另外,从图中可以看到,不同的锁对应于不同的Mark Word

    • 无锁:25bit空+31bit哈希值+1bit空+4bit分代年龄+1bit是否偏向锁+2bit锁标记
    • 偏向锁:54bit持有偏向锁的线程ID+2bit偏向时间戳+1bit空+4bit分代年龄+1bit是否偏向锁+2bit锁标记
    • 轻量锁:62bit栈中锁记录指针+2bit锁标记
    • 重量锁:62bit重量级锁指针+2bit锁标记

    JVM如何区分锁主要看两个字段:biased_locklock,对应关系如下:

    • biased_lock=0 lock=00:轻量级锁
    • biased_lock=0 lock=01:无锁
    • biased_lock=0 lock=10:重量级锁
    • biased_lock=0 lock=11GC标记
    • biased_lock=1 lock=01:偏向锁

    4 锁的运行时优化

    很多时候JVM都会对线程竞争的操作在JVM层面进行优化,尽可能解决竞争问题,也会试图消除不必要的竞争,实现的方法包括:

    • 偏向锁
    • 轻量级锁
    • 重量级锁
    • 自旋锁
    • 锁消除

    4.1 偏向锁(JDK15默认关闭)

    4.1.1 简介

    偏向锁是JDK 1.6提出的一种锁优化方式,核心思想是,如果线程没有竞争,则取消已经取得锁的线程同步操作,也就是说,某个线程获取到锁后,锁就会进入偏向模式,当线程再次请求该锁时,无需再次进行相关的同步操作,从而节省操作时间。而在此期间如果有其他线程进行了锁请求,则锁退出偏向模式。

    开启偏向锁的参数是-XX:+UseBiasedLocking,处于偏向锁时,Mark Word会记录获得锁的线程(54bit),通过该信息可以判断当前线程是否持有偏向锁。

    注意JDK15后默认关闭了偏向锁以及禁用了相关选项,可以参考JDK-8231264

    4.1.2 加锁流程

    偏向锁的加锁过程如下:

    • 第一步:访问Mark Word中的biased_lock是否设置为1lock是否设置为01,确认为可偏向状态,如果biased_lock0,则是无锁状态,直接通过CAS操作竞争锁,如果失败,执行第四步
    • 第二步:如果为可偏向状态,测试线程ID是否指向当前线程,如果是,到达第五步,否则到达第三步
    • 第三步:如果线程ID没有指向当前线程,通过CAS操作竞争锁,如果成功,将Mark Word中的线程ID设置为当前线程ID,然后执行第五步,如果失败,执行第四步
    • 第四步:如果CAS获取偏向锁失败,表示有竞争,开始锁撤销
    • 第五步:执行同步代码

    4.1.3 例子

    下面是一个简单的例子:

    public class Main {
        private static List<Integer> list = new Vector<>();
        public static void main(String[] args){
            long start = System.nanoTime();
            for (int i = 0; i < 1_0000_0000; i++) {
                list.add(i);
            }
            long end = System.nanoTime();
            System.out.println(end-start);
        }
    }
    

    Vectoradd是一个synchronized方法,使用如下参数测试:

    -XX:BiasedLockingStartupDelay=0 # 偏向锁启动时间,设置为0表示立即启动
    -XX:+UseBiasedLocking # 开启偏向锁
    

    输出如下:

    1664109780
    

    而将偏向锁关闭:

    -XX:BiasedLockingStartupDelay=0
    -XX:-UseBiasedLocking
    

    输出如下:

    2505048191
    

    可以看到偏向锁还是对系统性能有一定帮助的,但是需要注意偏向锁在锁竞争激烈的场合没有太强的优化效果,因为大量的竞争会导致持有锁的线程不停地切换,锁很难一直保持在偏向模式,这样不仅仅不能优化性能,反而因为频繁切换而导致性能下降,因此竞争激烈的场合可以尝试使用-XX:-UseBiasedLocking禁用偏向锁。

    4.2 轻量级锁

    4.2.1 简介

    如果偏向锁失败,那么JVM会让线程申请轻量级锁。轻量级锁在内部使用一个BasicObjectLock的对象实现,该对象内部由:

    • 一个BasicLock对象
    • 一个持有该锁的Java对象指针

    组成。BasicObjectLock对象放置在Java栈的栈帧中,在BasicLock对象还会维护一个叫displaced_header的字段,用于备份对象头部的Mark Word

    4.2.2 加锁流程

    • 第一步:通过Mark Word判断是否无锁(biased_lock是否为0lock01),如果是无锁,会创建一个叫锁记录(Lock Record)的空间,用于存储当前Mark Word的拷贝
    • 第二步:将对象头的Mark Word复制到锁记录中
    • 第三步:拷贝成功后,使用CAS操作尝试将锁对象Mark Word更新为指向锁记录的指针,并将线程栈帧中的锁记录的owner指向ObjectMark Word
    • 第四步:如果操作成功,那么就成功拥有了锁
    • 第五步:如果操作失败,JVM会检查Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,就可以直接进入同步块继续执行,否则会让当前线程尝试自旋获取锁,自旋到达一定次数后如果还没有获得锁,那么会膨胀为重量级锁

    4.3 重量级锁

    4.3.1 简介

    当轻量级锁自旋一定次数后还是无法获取锁,就会膨胀为重量级锁。相比起轻量级锁,Mak Word存放的是指向锁记录的指针,重量级锁中的Mark Word存放的是指向Object Monitor的指针,如下图所示:

    在这里插入图片描述

    (图源见文末)

    因为锁记录是线程私有的,不能满足多线程都能访问的需求,因此重量级锁中引入了能线程共享的ObjectMonitor

    4.3.2 加锁流程

    初次尝试加锁时,会先CAS尝试修改ObjectMonitor_owner字段,结果如下:

    • 第一种:锁没有其他线程占用,成功获取锁
    • 第二种:锁被其他线程占用,则当前线程重入锁,获取成功
    • 第三种:锁被锁记录占用,而锁记录是线程私有的,也就是属于当前线程的,这样就属于重入,重入次数为1
    • 第四种:都不满足,再次尝试加锁(调用EnterI()

    而再次尝试加锁的过程,是一个循环,不断尝试获取锁直到成功为止,流程简述如下:

    • 多次尝试获取锁
    • 获取失败把线程包装后放进阻塞队列
    • 再次尝试获取锁
    • 失败后将自己挂起
    • 被唤醒后继续尝试获取锁
    • 成功则退出循环,否则继续

    4.4 自旋锁

    自旋锁可以使线程没有取得锁时不被挂起,而是去执行一个空循环(也就是所谓的自旋),在若干个空循环后如果可以获取锁,则继续执行,如果不能,挂起当前线程。

    使用自旋锁后,线程被挂起的概率相对减小,线程执行的连贯性相对加强,因此对于锁竞争不是很激烈、锁占用并发时间很短的并发线程具有一定的积极意义,但是,对于竞争激烈且锁占用时间长的并发线程,自旋等待后仍无法获取锁,还是会被挂起,浪费了自旋时间。

    JDK1.6中提供了-XX:+UseSpinning参数开启自旋锁,但是JDK1.7后,自旋锁参数被取消,JVM不再支持由用户配置自旋锁,自旋锁总是被执行,次数由JVM调整。

    4.5 锁消除

    4.5.1 简介

    锁消除就是把不必要的锁给去掉,比如,在一些单线程环境下使用一些线程安全的类,比如StringBuffer,这样就可以基于逃逸分析技术可消除这些不必要的锁,从而提高性能。

    4.5.2 例子

    public class Main {
        private static final int CIRCLE = 200_0000;
        public static void main(String[] args){
            long start = System.nanoTime();
            for (int i = 0; i < CIRCLE; i++) {
                createStringBuffer("Test",String.valueOf(i));
            }
            long end = System.nanoTime();
            System.out.println(end-start);
        }
    
        private static String createStringBuffer(String s1,String s2){
            StringBuffer sb = new StringBuffer();
            sb.append(s1);
            sb.append(s2);
            return sb.toString();
        }
    }
    

    参数:

    -XX:+DoEscapeAnalysis
    -XX:-EliminateLocks
    -Xcomp
    -XX:-BackgroundCompilation
    -XX:BiasedLockingStartupDelay=0
    

    输出:

    260642198
    

    而开启锁消除后:

    -XX:+DoEscapeAnalysis
    -XX:+EliminateLocks
    -Xcomp
    -XX:-BackgroundCompilation
    -XX:BiasedLockingStartupDelay=0
    

    输出如下:

    253101105
    

    可以看到还是有一定性能提升的,但是提升不大。

    5 锁的应用层优化

    锁的应用层优化就是在代码层面对锁进行优化,方法包括:

    • 减少持有时间
    • 减小粒度
    • 锁分离
    • 锁粗化

    5.1 减少持有时间

    减少锁持有时间就是尽可能减少某个锁的占用时间,以减少线程互斥时间,比如:

    public synchronized void method(){
    	A();
    	B();
    	C();
    }
    

    如果只有B()是同步操作,那么可以优化为在必要时进行同步,也就是在执行B()的时候进行同步操作:

    public void method(){
    	A();
    	synchronized(this){
    		B();
    	}
    	C();
    }
    

    5.2 减小粒度

    所谓的减小锁粒度,就是指缩小锁定的对象范围,从而减小锁冲突的可能性,进而提高系统的并发能力。

    减小粒度也是一种削弱多线程竞争的有效手段,比如典型的就是ConcurrentHashMap,在JDK1.7中的segment就是一个很好的例子。每次并发操作的时候只加锁某个特定的segment,从而提高并发性能。

    5.3 锁分离

    锁分离就是将一个独占锁分成多个锁,比如LinkedBlockingQueue。在take()put()操作中,使用的并不是同一个锁,而是分离成了一个takeLock和一个putLock

    private final ReentrantLock takeLock;
    private final ReentrantLock putLock;
    

    初始化操作如下:

    this.takeLock = new ReentrantLock();
    this.notEmpty = this.takeLock.newCondition();
    this.putLock = new ReentrantLock();
    

    take()put()操作如下:

    public E take() throws InterruptedException {
        takeLock.lockInterruptibly();  //不能两个线程同时take
        //...
        try {
            //...
        } finally {
            takeLock.unlock();
        }
        //...
    }
    
    public void put(E e) throws InterruptedException {
    	//...
        putLock.lockInterruptibly();  //不能两个线程同时put
        try {
            //...
        } finally {
            putLock.unlock();
        }
    	//...
    }
    

    可以看到通过putLock以及takeLock两把锁实现了真正的取数据与写数据分离

    5.4 锁粗化

    通常情况下,为了保证多线程的有效并发,会要求每个线程持有锁的时间尽可能短,但是,如果对同一个锁不停请求,本身也会消耗资源,反而不利于性能优化,于是,在遇到一连串连续对同一个锁不断进行请求和释放的操作时,会把所有的锁操作整合成对锁的一次请求,减少对锁的请求同步次数,这个过程就叫锁粗化,比如

    public void method(){
    	synchronized(lock){
    		A();
    	}
    	synchronized(lock){
    		B();
    	}
    }
    

    会被整合成如下形式:

    public void method(){
    	synchronized(lock){
    		A();
    		B();
    	}
    }
    

    而在循环内申请锁,比如:

    for(int i=0;i<10;++i){
    	synchronized(lock){
    	}
    }
    

    应将锁粗化为

    synchronized(lock){
    	for(int i=0;i<10;++i){
    	}
    }
    

    6 无锁:CAS

    毫无疑问,为了保证多线程并发的安全,使用锁是一种最直观的方式,但是,锁的竞争有可能会称为瓶颈,因此,有没有不需要锁的方式去保证数据一致性呢?

    答案是有的,就是这一小节介绍的主角:CAS

    CAS就是Compare And Swap的缩写,CAS包含三个参数,形式为CAS(V,E,N),其中:

    • V表示内存地址值
    • E表示期望值
    • N表示新值

    只有当V的值等于E的值时,才会把V设置为N,如果V的值和N的值不一样,那么表示已经有其他线程做了更新,当前线程什么也不做,最后CAS返回当前V的值。

    CAS的操作是抱着乐观的态度进行的,总认为自己可以成功完成操作,当多个线程同时使用CAS操作同一个变量的时候,只会有一个胜出并成功更新,其他均会失败。失败的线程不会被挂起,仅被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

    7 参考

  • 相关阅读:
    I firmly believe
    深度学习常见的专业术语
    阿里、网易和腾讯面试题 C/C++
    Winfrom中关于toolStrip工具栏中按钮背景的设置
    非常完善的Log4net详细说明
    C#中 Var关键字
    C#中Dynamic关键字
    Python中threading的join和setDaemon的区别[带例子]
    pycharm常用快捷键
    ABP发布到iis
  • 原文地址:https://www.cnblogs.com/6b7b5fc3/p/14721708.html
Copyright © 2011-2022 走看看