Java并发系列 - CAS,锁升级,synchronized

2022-07-26 14:07:28

CAS

想要详细的了解cas,需要分别从是什么,什么用,以及实现原理三个方面入手,最后再去看cas在jdk并发包中的实践。

CAS详解

cas是compare and swap的缩写,比较并交换。cas在java内定义是自旋锁,自旋锁是一种比较轻量级的锁。

cas自旋锁实现的整体流程:

举个例子:

假如说,现在要利用cas计算x = 0; x+1这个操作。这个操作会有多个线程并发访问,如果不加锁,最终的计算结果肯定是错误的,所以这里可以使用cas自旋锁去计算。整体流程如下:

1)先把x=0这个值读取到内存中。

2)对x + 1 进行操作,得到一个新值N。

3)把新的值写回内存,在写回内存时,判断x的值是不是还等于0,如果是,则写入新值N到内存中。如果不是,则继续取出新的值,假如说取出新的值x=8,那么就对8进行+1的操作,继续得到一个新值N,然后看x是不是等于8,如果x还是等于8,则把新的值写入内存中,否则继续重复第三个步骤。整个过程是一个不停的循环和对比的过程。

ABA问题:

假如说,还是需要计算x =0;x+1,A线程对x的值进行了加法操作后又进行了减法操作,最终x的值的还是0,B线程这个时候进行cas自旋操作,发现x的值还是0,那么就对x进行加1操作。其实x的值是被更改过又改回来的。这就是典型的ABA问题。

如何处理ABA问题?

通用做法是通过加版本号,例如改一次加一个版本号,在JDK包内有一个类:

java.util.concurrent.atomic.AtomicStampedReference

具体的实例参考:https://www.javazhiyin.com/54447.html

ABA问题是否一定要解决?

这个需要从业务上去判断,如果是影响业务的ABA肯定需要解决,例如,资金转账的ABA问题。对于不影响业务的可忽略。

CAS的原子性如何保证?

注意:在cas自旋的过程中,对比和写入的过程必须是原子的,不可拆分的。否则仍然会出现数据一致性问题。在hotspot中通过中,通过lock cmpxchg命令实现。如下:

lock指令会锁死总线,只允许一个cpu内核与内存进行交互。所以可以保证原子性。

锁升级

在谈锁升级之前,首先要搞明白什么是重量级锁,什么是轻量级锁,以及整个锁升级的过程是什么样的?

关于锁的重量级和轻量级,实际是从用户态和内核态去定义的,一种比较粗的说法是:所有的轻量级的锁,都是在用户态完成,不需要再次向操作系统申请资源,重量级锁则与之相反。

这里有一个新的概念,什么是用户态和内核态?

这是一张保护环(Ring3到Ring 0 ,Ring 0即内核态),参考维基百科的图片,如下:

从用户态和内核态的维度来看:

重量级锁:主要是涉及到用户态和内核态的交互,需要JVM向用户态申请,用户态再向内核态申请。

轻量级锁:直接在用户态完成。(直接在用户态判断一个标识就好了,不需要涉及到底层内核的调度)

对象的内存布局:

在说锁的类型之前,需要先了解对象的内存布局,才可以充分理解后面的知识。

在HotSpot JVM内,对象在内存的布局包括三部分:

1)对象头(header),对象头又分为:MarkWord 和 ClassPoint (类指针)

2)对象实例数据(instance data)

3)对齐填充 (padding) , 不能被8整除的剩余的字节,需要补在这里。

这里需要重点关注MarkWord,用于存储运行时自身的数据,包括哈希码、GC分代年龄、偏向锁标记、线程持有的锁、偏向线程ID、偏向时间戳等。

可以使用JOL工具,查看对象头,如下的maven依赖:

        <dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.9</version>
        </dependency>

代码:

public class TestObjectHeader {

    public static class ObjectHeader{
        private Integer age;

        private String name;

        public Integer getAge() {
            return age;
        }

        public void setAge(Integer age) {
            this.age = age;
        }

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }
    }

    public static void main(String[] args) {
        ObjectHeader objectHeader = new ObjectHeader();
        objectHeader.setAge(99);
        objectHeader.setName("xxx");
        String toPrintable = ClassLayout.parseInstance(objectHeader).toPrintable();
        System.out.println(toPrintable);
    }
}

如上图,对象头内的前8个字节就是MarkWord区域(MarkWord部分保留了锁信息)。后面的4个字节,是ClassPoint区域。再后面的部分8个字节,就是instance data,最后的4个字节是补齐的padding。

上面了解了对象头信息后,需要进一步了解有哪些类型的锁会升级

锁的类型

偏向锁

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的本质:

在对象的头部markword内加上一个锁偏向的线程ID,每次只需要去对象头的markword部分判断标识即可。这样的效率是最高的。偏向锁不存在锁竞争。

偏向锁使用:

偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

偏向锁一定效率高吗?

正如上面所说,如果应用程序内有大量的锁处于竞争状态下,那么大量的锁竞争,就会产生锁升级,还需要从偏向锁升级到轻量级锁或者叫cas锁,这里又牵扯到一个锁升级的过程,所以效率也不见得高。

轻量级锁(自旋锁)

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

重量级锁

锁竞争过于激烈,由轻量级锁竞争升级为重量级锁,因为轻量级锁通过自旋也是需要消耗CPU资源。这个时候,需要借助操作系统来处理锁竞争,此时等待锁的线程都会进入一个队列内,进行排队阻塞状态。对应的就会衍生出公平锁和非公平锁。

不同的锁状态,在hotspot中内存布局的实现:

锁升级过程

1) 当new一个对象,偏向锁未启动时,产生的是轻量级锁,即cas自旋锁,当竞争愈发激烈,假如说有一万个线程在做cas操作,那么会消耗大量的cpu资源,这个时候就会升级成重量级锁,由操作系统来统一管控,得不到锁的统一放到一个队列里面去阻塞等待。

2) 当new一个对象,偏向锁已经启动,默认偏向锁会在系统启动后延迟启动,这个时候产生的是匿名偏向对象,再升级到偏向锁,通俗的说偏向锁,就是第一个来的线程,直接在markword内即一个标记而已,后面来的线程越来越多,竞争愈发激烈,升级成轻量级锁,就是多个线程在进行cas操作(cas操作过多会影响cpu资源),看谁能争抢到记录这个标记,如果争抢的线程越来越多,会进一步升级,升级为重量级锁,这个时候需要操作系统出面调控,让其他竞争的线程进行阻塞排队等待,停止自旋操作。

synchronized

从JDK1.5以后Synchronized做了优化,没有那么重量级,会有上面的一个锁升级的过程,首先是偏向锁 -- 轻量级锁 --- 重量级锁。

Java中的每一个对象都可以作为锁。具体表现为以下3种形式。

1)对于普通同步方法,锁是当前实例对象。

2)对于静态同步方法,锁是当前类的Class对象。

3)对于同步方法块,锁是Synchonized括号里配置的对象。

从JVM规范中可以看到Synchonized在JVM里的实现原理,是使用monitorenter和monitorexit指令实现的。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

总结

1)本文参考马士兵教育视频,《java并发编程的艺术》,美团博客https://tech.meituan.com/2018/11/15/java-lock.html,综合整理而来。其中美团博客整理了各种类型的锁,值得一看。

2)cas : compare and swap ,对比并且交互,是一个一直循环判断的过程,对比交互的原子性通过cpu底层的指令:lock cmpxchg进行保证。如果存在大量的自旋操作也是非常消耗cpu的。

3)锁升级:java对象在堆内存中的对象布局(对象头,实例数据,padding),其中对象头的markword部分记录了锁信息,如果偏向锁启动,第一个线程获取,获取到的就是偏向锁,有多个线程进行竞争,锁就会升级成轻量级锁,如果轻量级锁的竞争更加激烈,就会升级成重量级锁。

4)synchronized 锁是通过monitorenter和monitorexit两个指令实现的。

  • 作者:阿健2020
  • 原文链接:https://blog.csdn.net/y510662669/article/details/106885463
    更新时间:2022-07-26 14:07:28