【Java并发编程】锁机制——synchronized及乐观锁CAS

目录

一、synchronized原理及其使用

二、乐观锁CAS


锁概述图:

一、synchronized原理及其使用

        在了解synchronized的原理之前,我们先来了解一下Java对象头的基本格式

         Klass word 对象的类型,通过指针找到类对象,我们主要看Mark word的结构

正常情况下的Mark word由hashcode,age(分代年龄),偏向锁标志位和锁状态组成。不同的加锁状态下的Mark word组成不太一样。我们首先看一下重量级锁的原理。

每个Java对象都可以关联一个Monitor对象(操作系统),如果使用sychronized给对象上重量级锁后,该对象头的Mark Word中就会设置指向Monitor对象的指针(30位)表示该对象已经上锁。当线程访问到该对象,(线程每次过来都是先看锁有没有被拿走(Monitor中的Owner是否有线程对象主人),如果有的话就进入阻塞队列(EntryList,没有的话就拿到这把锁,Owner指向该线程). 当Owner线程释放锁就会通知entryList中的等待线程,然后线程竞争(非公平)成为下一任主人。同一个对象就会和同一个Monitor相关联,不同对象的monitor不同。如下图所示:

自旋优化:重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞(避免上下文切换所带来的开销)。如下图所示,自旋只有在多核cpu下才适用,自旋次数会自动调整(自适应)。
 

那么从上面可以看到,重量级锁需要关联到操作系统的Monitor,这样会增加系统开销,因此synchronized对此进行了优化,即轻量级锁。轻量级锁的使用场景是:如果一个对象有多个线程访问,但是多线程的访问的时间是错开的(无竞争),可以采用轻量级锁,语法与重量级锁的使用一样,对使用者透明。

        首先线程内会创建一个锁记录对象,用来存储锁定对象的Mark word

         将锁记录对象的对象引用指向要锁定的对象,并且通过CAS(原子)替换Object的Mark word,将其存入锁记录。

         交换锁记录对象的lock record 00(表示轻量级锁) 与对象的mark word成功,则锁加上了。什么时候失败呢,那就是对象的mark word的后两位已经是00了,即其他线程已经持有了锁。

         加锁失败有两种情况,一种是进入锁膨胀(轻量级锁升级为重量级锁)过程,一种是本身就说自己给对象加了锁,再加一次就会进入锁重入过程。锁重入时再创建的一个Lock Record只是用来作为重入的计数。

        形象理解:重量级锁是对象(门)底层关联了一把锁 ,线程竞争获取这把锁。而轻量级锁则是每个线程自己带了一把锁(锁记录对象),上来先看对象有没有被上锁,没有的话就用我的锁来锁住对象。

        从上面可以看到,轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。因此,Java 6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。如下图所示

         形象理解:轻量级锁每次上来都要进行一次CAS操作(看门上这把锁是不是自己的),这也会影响性能,那偏向锁就说把自己的名字刻在门上,避免了多余的CAS操作。

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,mark word最后3位为101,这时它的thread、epoch、age都为0
  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数-XX: BiasedLockingStartupDelay=来禁用延迟
     
  • 如果没有开启偏向锁,那么对象创建后,markword最后3位为001,这时它的hashcode、age都为0,第一次用到hashcode时才会赋值

偏向锁的撤销:

1.调用了对象的hashcode,但是偏向锁对象的Markword中存储的是线程的id,如果调用hashcode会导致偏向锁被撤销。

2.当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。

本来对象偏向t1线程,现在t2线程也要用这个对象,这个时候锁就会失效,升级为00轻量级锁。

批量重定向:

如果对象时多个线程访问,但是没有竞争,这时偏向锁偏向了线程T1时仍有机会重新偏向T2(而不是直接升级为轻量级锁),重偏向就会重新设置对象的ThreadID。因为偏向锁的撤销也是会耗费性能的。当撤销偏向锁阈值超过20次后,jvm会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至最新的加锁线程。


批量撤销:

当撤销偏向锁阈值超过40次后,jvm会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。

锁消除:

JIT编译器在编译的时候,进行逃逸分析。分析synchronized锁对象是不是只可能被一个线程加锁,不存在其他线程来竞争加锁的情况。这时就可以消除该锁了,提升执行效率。

Synchronize可以用有几种用法:

  1. 修饰一个代码块,被修饰的代码块称为同步代码块;
  2. 修饰一个实例方法,被修饰方法称为同步方法,作用范围为整个方法本身,作用对象为调用这个方法的实例对象;
  3. 修饰一个静态方法,其作用范围是整个静态方法,作用的对象为这个类(个人理解:因为静态方法是通过类名.的方式调用的,所以Synchronized锁的是整个类而不是对象);
  4. 修饰一个类,其作用范围是synchronized后面括号的部分,作用的对象是这个类的所有对象。

  • A. 无论synchronized关键字加在方法上还是对象上,如果它作用的对象是非静态的,则它取得的锁是对象;如果synchronized作用的对象是一个静态方法或一个类,则它取得的锁是对类,该类所有的对象同一把锁。 
  • B. 每个对象只有一个锁(lock)与之相关联,谁拿到这个锁谁就可以运行它所控制的那段代码。 
  • C. 实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制。

二、乐观锁CAS

对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁

悲观的意思是,在对一个同步数据访问之前就悲观的认为会有其他线程来一起并发的访问数据,在还没有进行真实的数据修改之前就先加好锁,让其他线程进不来。

而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。

根据从上面的概念描述我们可以发现:

  • ·悲观锁适合写操作多的场景,先加锁可以确保写操作的正确执行;
  • ·乐观锁适用于读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升;

CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。

CAS算法涉及到三个操作数:

  • 需要读写的内存值 V。
  • 进行比较的值 A。
  • 要写入的新值 B。

当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作(自旋)。CAS 的意思是:“我认为V的值应该是A,如果是,那么将其赋值为B,若不是,则不修改,并告诉我应该为多少”。CAS是项乐观技术---它抱着成功的希望进行更新,并且如果另一个线程在上次检查后更新了该变量,它能够发现错误。

注意:CAS存在一个很明显的问题,即ABA问题。

如果变量V初次读取的时候是A,并且在准备赋值的时候检查到它仍然是A,那能说明它的值没有被其他线程修改过了吗?如果在这段期间它的值曾经被改成了B,然后又改回A,那CAS操作就会误认为它从来没有被修改过。针对这种情况,java并发包中提供了一个带有标记的原子引用类"AtomicStampedReference",它可以通过控制变量值的版本来保证CAS的正确性。

上文提到java.util.concurrent包中的原子类,就是通过CAS来实现了乐观锁,那么我们进入原子类AtomicInteger的源码,看一下AtomicInteger的定义:

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    /*
     * This class intended to be implemented using VarHandles, but there
     * are unresolved cyclic startup dependencies.
     */
    private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
    private static final long VALUE;

    static {
        try {
            VALUE = U.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (ReflectiveOperationException e) {
            throw new Error(e);
        }
    }

    private volatile int value;
}

根据定义我们可以看出各属性的作用:

  • U: 获取并操作内存的数据。
  • VALUE: 存储value在AtomicInteger中的偏移量。
  • value: 存储AtomicInteger的int值,该属性需要借助volatile关键字保证其在线程间是可见的。

接下来,我们查看AtomicInteger的自增函数incrementAndGet()的源码时,发现自增函数底层调用的是unsafe.getAndAddInt()。但是由于JDK本身只有Unsafe.class,只通过class文件中的参数名,并不能很好的了解方法的作用,所以我们通过OpenJDK 8 来查看Unsafe的源码:

 根据OpenJDK 8的源码我们可以看出,getAndAddInt()循环获取给定对象o中的偏移量处的值v,然后判断内存值是否等于v。如果相等则将内存值设置为 v + delta,否则返回false,继续循环进行重试,直到设置成功才能退出循环,并且将旧值返回。整个“比较+更新”操作封装在compareAndSwapInt()中,在JNI里是借助于一个CPU指令完成的,属于原子操作,可以保证多个线程都能够看到同一个变量的修改值。

参考资料:

《Java并发编程与实战》

全面深入学习java并发编程_哔哩哔哩_bilibili

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

李孛欢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值