并发编程之同步锁

并发编程之同步锁

1 线程的安全性

并发给我们带来的问题就是,当多个线程操作同一个数据的时候,往往不能得到我们预期的结果,造成这个问题的原因是什么呢?其实就是该数据对多个线程没有可见性,这些线程不能有序性的去操作这个公共的数据,操作数据时还不是原子操作,所以导致预期结果不一致。因此我们可以总结出线程的安全性的三个体现:

  • 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作(Synchronized、AtomicXXX、Lock)。
  • 可见性:一个线程对主内存进行了修改,可以及时被其他线程观察到(Synchronized、volatile)。
  • 有序性:如果两个线程不能从 happens-before原则观察出来,那么就不能观察他们的有序性,虚拟机可以随意的对他们进行重排序,导致其观察观察结果杂乱无序(Synchronized、volatile)。

1.1 原子性问题

在下面的代码中,演示了两个线程分别去调用demo.incr()方法来对变量i进行叠加,预期结果应应该是20000,但实际结果却是小于等于20000的值。

 public class Demo {
    int i = 0;
    public void incr(){
        i++;
    }
    public static void main(String[] args) {
        Demo demo = new Demo();
        Thread[] threads=new Thread[2];
        for (int  j = 0;j<2;j++) {
          	// 创建两个线程
			threads[j]=new Thread(() -> { 
              	// 每个线程跑10000次
				for (int k=0;k<10000;k++) { 
	            	demo.incr();
                }
			});
            threads[j].start();
        }
        try {
            threads[0].join();
            threads[1].join();
        } catch (InterruptedException e) {
            e.printStackTrace();
		}
        System.out.println(demo.i);
    }
}
1.1.1 问题的原因

这个就是典型的线程安全问题中原子性问题的体现了。

在上面的代码中,i++是属于Java高级语言中的编程指令,而这些指令最终可能会有多条CPU指令来组成,i++最终会生成3条指令,通过javap -v xxx.class查看字节码指令如下:

 public incr()V
   	L0
    LINENUMBER 13 L0
    ALOAD 0
    DUP
    GETFIELD com/gupaoedu/pb/Demo.i : I     // 访问变量i
    ICONST_1                                // 将整形常量1放入操作数栈
		IADD                                    // 把操作数栈中的常量1出栈并相加,将相加的结果放入操作数栈
    PUTFIELD com/gupaoedu/pb/Demo.i : I     // 访问类字段(类变量),复制给Demo.i这个变量

这三个操作如果要满足原子性,那就需要保证线程在执行这个指令时,不允许其他线程干扰。

1.1.2 图解问题本质

一个CPU核心在同一时刻只能执行一个线程,如果线程数量远远大于CPU核心数,那么就会发生线程的切换,这个切换动作可以发生在任何一个CPU指令执行完之前。

对于i++这三个cpu指令来说,如果线程A在执行指令1之后,做了线程切换,切换到了线程B,线程B同样执行i++的三个CPU的指令,执行顺序如下图所示,就会导致结果是1,而不是2。

请添加图片描述

这就是在多线程环境下,存在的原子性问题,那么应该怎么解决这个问题呢?

从上面的图中可以看出,表面上是多个线程对于同一个变量的操作,实际上是i++这行代码它不是原子性的,所以才导致了在多线程环境下出现这样的问题。

也就是说,我们只需要保证i++这个指令运行期间,在同一时刻只能由一个线程来访问,就可以解决这个问题了。所以我们需要用到同步锁Synchronized来解决。

2 Synchronized的基本应用

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

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

2.1 锁的实现模型理解

Synchronized能帮我们做些什么呢?为什么能解决原子性问题?

在没有加锁之前,多个线程去调用incr()方法时,没有任何限制,可以同时拿到这个i的值进行i+1操作,但是当加了Synchronized锁之后,线程A和线程B就由并行执行变成了串行执行了。

请添加图片描述

2.2 Synchronized的原理

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

2.2.1 Markword对象头

Markword是对象头的意思,简单理解,就是一个对象,在JVM内存中的存储形式。

在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例对象(Instance Data)、对齐填充(Padding)

请添加图片描述

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

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

  • 添加jol依赖

    <dependency>
        <groupId>org.openjdk.jol</groupId>
        <artifactId>jol-core</artifactId>
        <version>0.9</version>
    </dependency>
    
  • 编写测试代码,在不加锁的情况下,打印对象头信息

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());
    }
}
  • 输出内容
com.test.Demo object internals:
 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   java.lang.Object Demo.o
(object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

2.3 Synchronized锁的升级

jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

锁主要存在四个状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。

这么设计的目的,其实是为了重量级锁带来的性能开销,尽可能的在无锁状态下解决线程并发问题。其中偏向锁和轻量级锁的底层实现是基于自旋锁,它相对于重量级锁来说,算是一种无锁的实现。

请添加图片描述

  • 默认情况下偏向锁是开启状态,偏向的线程ID是0,偏向一个Anonymous BiasedLock。

  • 如果有线程去抢占锁,那么这个时候线程会先去抢占偏向锁,也就是把markword的线程ID改为当前抢占锁的线程ID。

  • 如果有线程竞争,这个时候会撤销偏向锁,升级为轻量级锁,线程在自己的栈帧中会创建一个LockRecord,用CAS操作把markword设置为指向自己这个线程的LR的指针,设置成功后表示抢占到锁。

  • 如果竞争加剧,比如有线程超过10次自旋(-XX:PreBlockSpin参数设置),或者自旋线程数超过CPU核心数的一倍,在1.6之后,加入了自适应自旋Adapative Self Spinning,JVM会根据上次竞争的情况来自动控制自旋的时间。

  • 升级到重量级锁,向操作系统申请资源,然后线程被挂起进入到等待队列。

2.3.1 轻量级锁的获取

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

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()); 
      	synchronized (demo){
    		System.out.println(ClassLayout.parseInstance(demo).toPrintable());
		}
	} 
}

得到的对象布局信息如下

 // 在未加锁之前,对象头中的第一个字节最后三位为 [001], 其中最后两位 [01]表示无锁,第一位[0]也表示无锁
com.test.Demo object internals:
 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   java.lang.Object Demo.o
(object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
// 下面部分是加锁之后的对象布局变化
// 其中在前4个字节中,第一个字节最后三位都是[000], 后两位00表示轻量级锁,第一位为[0],表示当前不是偏向锁状态。
com.gupaoedu.pb.Demo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)													 d8 f0
d5 02 (11011[000] 11110000 11010101 00000010) (47575256)
      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 Demo.o
(object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
Process finished with exit code 0

这里会有疑惑,不是说锁的升级是基于线程竞争情况来实现升级,从偏向锁到轻量级锁再到重量级锁的吗?为什么这里没有线程竞争,他的锁标记是轻量级锁呢?答案需要在偏向锁的获取以及原理里寻找。

2.3.2 偏向锁的获取

默认情况下,偏向锁的开启有个延迟,默认是4s,为什么要这么设计呢?

因为JVM虚拟机自己有一些默认启动的线程,这些线程里有很多的Synchronized代码,这些Synchronized代码在启动的时候就会触发竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁升级和撤销,效率较低。

通过JVM参数-XX:BiasedLockingStartupDelay=0可以将延迟设置为0。

再次运行代码

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()); 
      	synchronized (demo){
    		System.out.println(ClassLayout.parseInstance(demo).toPrintable());
		}
	} 
}

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

 com.test.Demo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           05 00
00 00 (00000101 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 Demo.o
(object)
 Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
com.gupaoedu.pb.Demo object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
			0     4 									 (object header)													 05 30
4a 03 (00000101 00110000 01001010 00000011) (55193605) 
   		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 Demo.o
(object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

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

2.3.3 重量级锁获取

在竞争比较激烈的情况下,线程一直无法获取到锁的时候,就会升级到重量级锁。

下面的案例,通过两个线程来模拟竞争的场景。

 public static void main(String[] args) {
    Demo testDemo = new Demo();
    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());
    }
}

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

 com.test.Demo object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)													 8a 20 5e 26
(10001010 00100000 01011110 00100110) (643702922)
      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
t1 lock ing
com.gupaoedu.pb.Demo object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)													 8a 20 5e 26
(10001010 00100000 01011110 00100110) (643702922)
      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

3 CAS

CAS在Synchronized的底层用的比较多,他的全程有两种:

  • Compare and swap
  • Compare and exchange

就是比较并交换的意思,它可以保证在多线程环境下对于一个变量修改的原子性。

CAS原理很简单,包含三个值:当前内存值(V)、预期原来的值(E)、期待更新的值(N)。

请添加图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值