【并发编程】并发编程的原子性和synchronized原理介绍

并发编程有三大问题:
原子性,有序性,可见性

原子性问题

public class Demo {
    
    
    int i = 0;
    public void incr(){
    
    
        i++;
    }
    public static void main(String[] args) {
    
    
        Demo demo = new Demo();
        Thread thread1 = new Thread(() -> {
    
    
            for (int j = 0; j < 1000; j++) {
    
    
                demo.incr();
            }
        });
        Thread thread2 = new Thread(() -> {
    
    
            for (int j = 0; j < 1000; j++) {
    
    
                demo.incr();
            }
        });

        thread1.start();
        thread2.start();
        System.out.println(demo.i);
    }
}

i++ 在java代码中是一条指令,但是这个指令最终可能会由多条CPU指令来组成,像i++最终会生成3个指令
我们可以通过javap -v Demo.class来查看
在这里插入图片描述
上述命令依次为:
getfield 访问变量i
iconst_1 将整形常量1放入操作数栈
iadd : 把操作数栈中的常量1出栈并相加,将相加的结果放入操作数栈
putfield : 将上述操作结果赋值给Demo.i这个变量

如果要满足原子性,就要求线程在执行i++命令时不被其他线程干扰

为了保证原子性,我们可以使用同步锁Synchronized

Synchronized的基本用法

synchronized有三种方式来加锁 :

  1. 修饰实例方法,作用于当前实例,进入同步代码前要获得当前实例的锁
  2. 静态方法,作用于当前类对象,进入同步代码前要获得当前类对象的锁
  3. 修饰代码块,可以由使用方指定加锁对象,进入同步代码前要获得给定对象的锁

Synchronized的原理

public class MarkwordDemo {
    
    
    public static void main(String[] args){
    
    
        MarkwordDemo markwordDemo = new MarkwordDemo();
        synchronized (markwordDemo){
    
    
            System.out.println("抢到锁,执行代码 ...");
            System.out.println("释放锁..");
        }
    }
}

将上述代码反编译成字节码javap -c MarkwordDemo.class
在这里插入图片描述

Monitor:

  1. monitorenter指令理解为加锁,monitorexit理解为释放锁。
  2. 每个对象维护着一个记录被锁次数的计数器
  3. 执行monitorenter后,计数器加1,执行monitorexit后,计数器减1 (可重入)
  4. 当计数器为0的时候。锁将被释放,其他线程便可以获得锁

所有对象都可以作为synchronized同步锁的加锁对象,线程A抢到锁了,线程B要想知道当前锁被抢占了,就需要有一个地方来存储这个事件标记,这个地方就是Java对象头里的Markword. (synchronized在执行同步代码块的时候是获取对象的monitor,即操作Java对象头里的Markword)

Markword对象头,简单理解,就是一个对象在JVM内存中的布局或者存储的形式。

Markword对象头

jdk8u: markOop.hpp
在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header,包含对象标记Markword和类型指针)、实例数据(Instance Data)、对齐填充(Padding)
在这里插入图片描述

对象标记(mark-word)
在64位系统中,对象标记占8个字节,对象标记中包含hashcode、GC年龄、锁标记
不同锁状态下64位Markword的结构如下图,不同锁状态的分析会在下面进行讲解:
在这里插入图片描述
1) hashCode:当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该值会移动到管程Monitor中。
2) 分代年龄, 4位 每次GC时未被回收累加的年龄就记录在这里,默认达到15次就进入老年代(-XX:MaxTenuringThreshold 可通过该配置修改进入老年代的阈值,因为分代年龄只有4位,所以最大值就是15
3)是否为偏向锁 1位
4)锁标志位 2位 可以表示4种不同的状态,每种状态对应的锁标志位在后面介绍

Klass Pointer: Class对象的类型指针,Jdk1.8默认开启指针压缩后为4字节,关闭指针压缩( -XX:-UseCompressedOops )后,长度为8字节。其指向的位置是对象对应的Class对象(其对应的元数据对象)的内存地址。
实例数据: 包括对象的所有成员变量,大小由各个成员变量决定,比如:byte占1个字节8bit、int占4个字节32bit。
对齐填充:最后这段空间补全并非必须,仅仅为了起到占位符的作用。由于HotSpot虚拟机的内存管理系统要求对象起始地址必须是8字节的整数倍,所以对象头正好是8字节的倍数。因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

Synchronized锁的升级

Synchronized锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。之所以要设计这么多种不同的锁是为了最大限度的减少锁操作的开销,尤其是重量级锁,它需要在用户态和内核态之间做切换,是很消耗资源的。

这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级, 意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁

  • 默认情况下是偏向锁是开启状态的,偏向的线程ID是0(此时表示没有任何线程占有这个锁)

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要通过CAS操作来加锁和解锁,只需判断下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果存储的是自己的线程id,则表示线程已经获得了锁。如果不是,则需要判断下该锁对象是否设置的使用偏向锁(Mark Word中偏向锁的标识是否为1), 如果没有设置,则通过CAS竞争锁;如果设置了,则尝试通过CAS将对象头的偏向锁指向自己的线程。

偏向锁的获得和撤销流程
在这里插入图片描述

轻量级锁

因为大部分线程在获取到锁之后,在很短的时间内就会释放锁,为了避免用户态和内核态的切换,减少资源开销,新的线程会尝试通过自旋的方式去获得锁

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

轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。
在这里插入图片描述

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),会设置一个自旋次数的上限,
-XX:PreBlockSpin参数配置, 当线程自旋次数超过这个上限时则会将锁升级到重量级锁(在1.6之后,加入了自适应自旋Adapative Self Spinning. JVM会根据上次竞争的情况来自动控制自旋的时间)。
一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。 当锁处于重量级锁下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会再次进行抢锁。

重量级锁

synchronized 的重量级锁是通过对象内部的 监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,同时也会将当前线程挂起,进入到等待队列。

这也是为什么JVM要对synchronized的锁状态做这么多优化的原因。

synchronized实战,查看不同锁状态下的Markword对象头信息

通过ClassLayout打印对象头

我们可以通过jol查看对象的内存布局

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

我们先查看下jvm的信息

    public static void main(String[] args) {
    
    
        //查看字节序
        System.out.println(ByteOrder.nativeOrder());
        //打印当前jvm信息
        System.out.println("======================================");
        System.out.println(VM.current().details());
    }

在这里插入图片描述
从上面的输出中,我们可以看到:Objects are 8 bytes aligned,这意味着所有的对象分配的字节都是8的整数倍。

从上面的LITTLE_ENDIAN可以判定内存中字节序使用的是小端模式。

大端字节序:高位字节在前,低位字节在后,这是人类读写数值的方法。
小端字节序:低位字节在前,高位字节在后。

计算机电路先处理低位字节,效率比较高,因为计算都是从低位开始的。所以,计算机的内部处理都是小端字节序。

下面是一个无锁状态下对象的内存布局

public class MarkwordDemo {
    
    

    private Integer age = 1;
    private Long number = 1L;

    public static void main(String[] args){
    
    
        MarkwordDemo markwordDemo = new MarkwordDemo();
        System.out.println(ClassLayout.parseInstance(markwordDemo).toPrintable());
    }

}
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      ## 对象标记
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      ## 类型指针
      8     4        (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
      ## 对齐填充
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

前面说过,jvm采用的是小端模式,数据的高字节存储在后,低字节存储在前。需要注意的是,这里每次输出的都是4个字节,在每个字节的内部,jol已经帮我们做了处理。因此现在看起来第一行的第一个字节的最后三位才是我们需要关注的偏向锁标识+锁状态位(无锁状态下是0|01)。

轻量级锁状态下的状态位

    public static void main(String[] args){
    
    
        MarkwordDemo markwordDemo = new MarkwordDemo();
        System.out.println(ClassLayout.parseInstance(markwordDemo).toPrintable());
        synchronized (markwordDemo){
    
    
            System.out.println(ClassLayout.parseInstance(markwordDemo).toPrintable());
            System.out.println("抢到锁,执行代码 ...");
        }
    }

在这里插入图片描述
按照前面关于锁升级的声明,在没有其他线程争抢锁的时候,其锁类型应该是偏向锁,为什么和对象实际的内存布局不一致?

偏向锁的默认延迟设置

默认情况下,偏向锁的开启是有个延迟,默认是4秒。
这是因为JVM虚拟机自己有一些默认启动的线程,这些线程里面有很多的synchronized的代码块,这些代码启动的时候就会触发竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁的升级和撤销,效率较低,所以JVM对偏向锁的开启设置了个默认延迟的时间

可以通过下面这个JVM参数可以将延迟时间设置为0.

-XX:BiasedLockingStartupDelay=0

在这里插入图片描述
这里两次打印的锁状态都是101(偏向锁),是因为在默认关闭偏向锁延迟的状态下,会有配置的匿名对象获得偏向锁

重量级锁状态下的状态位

 public static void main(String[] args){
    
    
        MarkwordDemo markwordDemo = new MarkwordDemo();
        Thread thread1 = new Thread(() -> {
    
    
            synchronized (markwordDemo){
    
    
                System.out.println(ClassLayout.parseInstance(markwordDemo).toPrintable());
            }
        });
        thread1.start();
        synchronized (markwordDemo){
    
    
            System.out.println(ClassLayout.parseInstance(markwordDemo).toPrintable());
        }

    }

在这里插入图片描述
再来看一个从偏向锁升级到轻量级锁的例子, thread1先拿到锁,此时没有其他线程和其竞争,所以是偏向锁;主线程在thread1释放锁之后开始执行,由于此时对象标记里存储了thread1的线程id,CAS失败,所以会升级会轻量级锁

   public static void main(String[] args) throws InterruptedException {
    
    
        MarkwordDemo markwordDemo = new MarkwordDemo();
        Thread thread1 = new Thread(() -> {
    
    
            synchronized (markwordDemo){
    
    
                System.out.println("thread1开始执行");
                System.out.println(ClassLayout.parseInstance(markwordDemo).toPrintable());
                System.out.println("thread1执行完毕");

            }
        });
        thread1.start();
        Thread.sleep(15000);
        synchronized (markwordDemo){
    
    
            System.out.println("main线程开始执行");
            System.out.println(ClassLayout.parseInstance(markwordDemo).toPrintable());
        }

    }

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_35448165/article/details/129715574