并发编程之二同步锁

目标

  • 关于线程安全问题简述
  • Java中的同步锁Synchronize
  • MarkWord对象头
  • Synchronize锁升级
  • CAS机制

如果多个线程在做同一件事情的时候,会涉及到线程安全问题

  • 原子性 Synchronized, AtomicXXX、Lock
  • 可见性 Synchronized, volatile
  • 有序性 Synchronized,volatile

每个特性所表现出的显现都是不一样的,先针对【原子性】展开
先看一个案例

// 两个线程针对一个变量进行操作
public class AtomicDemo {

    int i = 0;
    public void incr(){
        i++;
    }
    public static void main(String[] args) throws InterruptedException {
        AtomicDemo ad = new AtomicDemo();
        Thread[] threads = new Thread[2];
        for (int i = 0; i < 2 ; i++) {
            threads[i] = new Thread(()->{//创建两个线程
                for (int j = 0; j < 10000 ; j++) {//每个线程跑10000次
                    ad.incr();
                }
            });
            threads[i].start();
            threads[0].join();
            threads[1].join();
            System.out.println("Result:"+ad.i);
        }
    }
}
打印:
Result:12927
Result:12273
Result:20000
每次的执行结果都是不一样的,说明变量i不是原子的
问题的原因是,count++是属于Java高级语言中的编程指令,而这些指令最终可能会有多条CPU指令来组成,而count++最终会生成3条指令
// 通过命令查看字节码指令
javap -v AtomicDemo.class
// An highlighted block
 public void incr();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2     // Field i:I   访问变量I
         5: iconst_1			// 将整形常量1放入操作数栈
         6: iadd				// 把操作数栈中的常量1出栈并相加,将相加的结果放入操作数栈
         7: putfield      #2    // Field i:I   访问类字段(类变量),复制给i这个变量
        10: return
      LineNumberTable:
        line 7: 0
        line 8: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Lorg/example/AtomicDemo;


这三个操作,如果要满足原子性,那么就需要保证某个线程在执行这个指令时,不允许其他线程干扰,
然后实际上,确实会存在这个问题。

前面我们说过,一个CPU核心在同一时刻只能执行一个线程,如果线程数量远远大于CPU核心数,就会发生线程的切换,这个切换动作可以发生在任何一条CPU指令执行完之前。
对于 i++ 这三个cpu指令来说,如果线程A在执行指令1之后,做了线程切换,假设切换到线程B,线程B同样执行CPU指令,执行的顺序如下图所示。就会导致最终的结果是1,而不是2,请看图解
在这里插入图片描述
这就是在多线程环境下,存在的原子性问题,那么,怎么解决这个问题呢?
认真观察上面这个图,表面上是多个线程对于同一个变量的操作,实际上是count++这行代码,它不是原子的。所以才导致在多线程环境下出现这样一个问题。
也就是说,我们只需要保证,count++这个指令在运行期间,在同一时刻只能由一个线程来访问,就可以解决问题。
这就需要引出本章内容, 同步锁Synchronized

Synchronized的基本应用

synchronized有三种方式来加锁,不同的修饰类型,代表锁的控制粒度:

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

锁的实现模型

Synchronized到底帮我们做了什么,为什么能够解决原子性呢?
在没有加锁之前,多个线程去调用incr()方法时,没有任何限制,都是可以同时拿到这个i的值进行 ++ 操作,但是当加了Synchronized锁之后,线程A和B就由并行执行变成了串行执行。
在这里插入图片描述

Synchronized的原理

Synchronized是如何实现锁的,以及锁的信息是存储在哪里? 就拿上面分析的图来说,线程A抢到锁了,线程B怎么知道当前锁被抢占了,这个地方一定会有一个标记来实现,而且这个标记一定是存储在某个地方。

Markword对象头

对象头的意思,简单理解,就是一个对象,在JVM内存中的布局或者存储的形式。
Hotsport --> jdk8u: markOop.hpp
在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
在这里插入图片描述

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

锁状态

  • 既然有锁,那对象头中也必然也会有状态标识锁的状态
    在这里插入图片描述

通过ClassLayout打印对象头

为了更加直观的看到对象的存储和实现,我们可以使用JOL查看对象的内存布局。

  • 添加jol依赖
<dependency>
	<groupId>org.openjdk.jol</groupId>
	<artifactId>jol-core</artifactId>
	<version>0.9</version>
</dependency>
  • 编写测试代码,在不加锁的情况下,对象头的信息打印
// An highlighted block
public class Demo {
     Object o=new Object();
      public static void main(String[] args) {
          Demo demo=new Demo(); //o这个对象,在内存中是如何存储和布局的。
          System.out.println(ClassLayout.parseInstance(demo).toPrintable());
      }
}

打印:
org.example.LayoutDemo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           01 00 00 00 (00000[001] 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   java.lang.Object LayoutDemo.o                              (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

只有一个线程访问时  内存布局打印[001]代表无锁

关于Synchronized锁的升级

Jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。
这么设计的目的,其实是为了减少重量级锁带来的性能开销,尽可能的在无锁状态下解决线程并发问题,其中偏向锁和轻量级锁的底层实现是基于自旋锁(自旋锁:顾名思义就是在未抢占到锁的时候不断的去循环尝试去获取锁,默认是循环10次,超过10次会升级为重量级锁),它相对于重量级锁来说,算是一种无锁的实现。
在这里插入图片描述

  • 默认情况下是偏向锁是开启状态,偏向的线程ID是0,偏向一个Anonymous BiasedLock
  • 如果有线程去抢占锁,那么这个时候线程会先去抢占偏向锁,也就是把markword的线程ID改为当前抢占锁的线程ID的过程
  • 如果有线程竞争,这个时候会撤销偏向锁,升级到轻量级锁,线程在自己的线程栈帧中会创建一个LockRecord,用CAS(文末有介绍,就是通过比较将旧值更新为新值)操作把markword设置为指向自己这个线程的LockRecord的指针,设置成功后表示抢占到锁。
  • 如果竞争加剧,比如有线程超过10次自旋(-XX:PreBlockSpin参数配置),或者自旋线程数超过CPU核心数的一半,在1.6之后,加入了自适应自旋Adapative Self Spinning. JVM会根据上次竞争的情况来自动控制自旋的时间。升级到重量级锁,向操作系统申请资源, Linux Mutex,然后线程被挂起进入到等待队列。

轻量级锁的获取及原理

接下来,我们通过下面的例子来演示一下,通过加锁之后继续打印对象布局信息,来关注对象头里面的变化。

// An highlighted block
	  Object o=new Object();
      public static void main(String[] args) {
          LayoutDemo demo=new LayoutDemo(); //o这个对象,在内存中是如何存储和布局的。
          System.out.println(ClassLayout.parseInstance(demo).toPrintable());
          synchronized (demo){
              System.out.println(ClassLayout.parseInstance(demo).toPrintable());
          }
      }
打印:
// 在未加锁之前,对象头中的第一个字节最后三位为 [001],其中 
// 最后两位 [01]表示无锁,
// 第一位[0]也表示无锁
org.example.LayoutDemo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           01 00 00 00 (00000[001] 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   java.lang.Object LayoutDemo.o                              (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
// 下面部分是加锁之后的对象布局变化,其中在前4个字节中,
// 第一个字节最后三位都是[000], 后两位00表示轻量级锁,
// 第一位为[0],表示当前不是偏向锁状态。
org.example.LayoutDemo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           a8 f1 5f e5 (10101[000]  11110001 01011111 11100101) (-446697048)
      4     4                    (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      8     4                    (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
     12     4   java.lang.Object LayoutDemo.o                              (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
第一个打印的锁状态是[001]  -无锁
第二个打印的锁状态是[000]  -轻量级锁
锁的升级不是基于线程竞争情况来实现从偏向锁到轻量级锁再到重量级锁的升级的吗?
可是为什么这里明明没有竞争,它的锁的标记是轻量级锁呢?

偏向锁的获取及原理

默认情况下,偏向锁的开启是有个延迟,默认是4秒。为什么这么设计呢?
因为JVM虚拟机自己有一些默认启动的线程,这些线程里面有很多的Synchronized代码,这些Synchronized代码启动的时候就会触发竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁的升级和撤销,效率较低。
通过下面这个JVM参数可以将延迟设置为0.

-XX:BiasedLockingStartupDelay=0

再次运行下面的代码。

// 偏向锁
 	  Object o=new Object();
      public static void main(String[] args) {
          LayoutDemo demo=new LayoutDemo(); //o这个对象,在内存中是如何存储和布局的。
          System.out.println(ClassLayout.parseInstance(demo).toPrintable());
          synchronized (demo){
              System.out.println(ClassLayout.parseInstance(demo).toPrintable());
          }
      }
打印:
org.example.LayoutDemo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           05 00 00 00 (00000[101] 00000000 00000000 00000000) (5)
      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   java.lang.Object LayoutDemo.o                              (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

org.example.LayoutDemo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           05 58 97 bb (00000[101] 01011000 10010111 10111011) (-1147709435)
      4     4                    (object header)                           b1 02 00 00 (10110001 00000010 00000000 00000000) (689)
      8     4                    (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
     12     4   java.lang.Object LayoutDemo.o                              (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

得到如上的对象布局,可以看到对象头中的的高位第一个字节最后三位数为[101],表示当前为偏向锁状态。

这里的第一个对象和第二个对象的锁状态都是101,是因为偏向锁打开状态下,默认会有配置匿名的对象获得偏向锁。

重量级锁的获取

在竞争比较激烈的情况下,线程一直无法获得锁的时候,就会升级到重量级锁。
仔细观察下面的案例,通过两个线程来模拟竞争的场景。

// 重量级锁
		
        public static void main(String[] args) {
            LayoutDemo testDemo = new LayoutDemo();
            Thread t1 = new Thread(() -> {
                synchronized (testDemo) {
                    System.out.println("t1 lock ing");
                    System.out.println(ClassLayout.parseInstance(testDemo).toPrintable());
                }
            });
            t1.start();
            synchronized (testDemo) {
                System.out.println("main lock ing");
                System.out.println(ClassLayout.parseInstance(testDemo).toPrintable());
            }
        }

打印:
main lock ing
org.example.LayoutDemo object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           9a e7 3a 1f (10011[010] 11100111 00111010 00011111) (523954074)
      4     4        (object header)                           17 02 00 00 (00010111 00000010 00000000 00000000) (535)
      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

t1 lock ing
org.example.LayoutDemo object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           9a e7 3a 1f (10011[010] 11100111 00111010 00011111) (523954074)
      4     4        (object header)                           17 02 00 00 (00010111 00000010 00000000 00000000) (535)
      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

从结果可以看出,在竞争的情况下锁的标记为 [010] ,其中所标记 [10]表示重量级锁

CAS

CAS这个在Synchronized底层用得非常多,它的全称有两种

  • Compare and swap
  • Compare and exchange
    就是比较并交换的意思。它可以保证在多线程环境下对于一个变量修改的原子性。
    CAS的原理很简单,包含三个值当前内存值(V)、预期原来的值(E)以及期待更新的值(N)。

CAS核心算法:执行函数:CAS(V,E,N)

 V表示准备要被更新的变量       
 E表示我们提供的 期望的值
 N表示新值 ,准备更新V的值
compare and swap,解决多线程并行情况下使用锁造成性能损耗的一种机制,CAS操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。
如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。
无论哪种情况,它都会在CAS指令之前返回该位置的值。
CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。"

CAS可能的问题

  • ABA问题

    CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化就更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了 —> 这就是所谓的ABA问题。

    ABA问题的解决思路其实也很简单,就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A了。

JDK中对原子操作的支持可以参考其他大佬的分享
链接: https://blog.csdn.net/nrsc272420199/article/details/105032873.

本文仅做个人学习使用

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值