热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

Java并发编程——Synchronized同步锁

原子性问题在下面的案例中,演示了两个线程分别去去调用demo.incr方法来对i这个变量进行叠加,预期结果应该是20000,但是实际结果却是小于等于20000的

原子性问题

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

public class AutoDemo {
int i = 0;
// synchronized 排它锁 互斥锁 --- 同一时刻只能由一个线程执行
public synchronized void incr() {
i++;
}
public static void main(String[] args) throws InterruptedException {
AutoDemo autoDemo = new AutoDemo();
Thread[] threads = new Thread[2];
for (int i = 0; i < 2; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
autoDemo.incr();
}
});
threads[i].start();
}
threads[0].join();
threads[1].join();
System.out.println("Result:" + autoDemo.i);
}
}

问题的原因

这个就是典型的线程安全问题中原子性问题的体现。那什么是原子性呢?

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

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 访问类字段 赋值给 Demo.i这个变量
10: return

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


图解问题本质

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

对于 i++ 这三个cpu指令来说,如果线程A在执行指令1之后,做了线程切换,假设切换到线程B,线程B同样执行CPU指令,执行的顺序如下图所示。就会导致最终的结果是1,而不是2。
在这里插入图片描述
这就是在多线程环境下,存在的原子性问题,那么,怎么解决这个问题呢?

大家认真观察上面这个图,表面上是多个线程对于同一个变量的操作,实际上是count++这行代码,它不是原子的。所以才导致在多线程环境下出现这样一个问题。

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


Java 中的同步锁 Synchronized

synchronized 作用范围

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



  1. 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例对象的锁。

    public synchronized void m1() {}


  2. 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁。



public synchronized static void m3() {}


  1. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。需要注意以下加锁的区别。

    public void m2() {
    //指定加锁对象 ,进入同步代码块前要获得给定对象的锁。
    synchronized (SynchronizedDemo.class) {
    }
    }

    public void m2() {
    //进入同步代码块前要获得当前实例的锁。
    synchronized (this) {
    }
    }



锁的实现模型理解

Synchronized到底帮我们做了什么,为什么能够解决原子性呢?

在没有加锁之前,多个线程去调用incr()方法时,没有任何限制,都是可以同时拿到这个i的值进行 ++ 操作,但是当加了Synchronized锁之后,线程A和B就由并行执行变成了串行执行。影响锁的作用范围,其实本质上就是对象的生命周期。
在这里插入图片描述


实现原理

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


MarkWord对象头

这就要引出Markword对象头这个概念了,它是对象头的意思,简单理解,就是一个对象,在JVM内存中的布局或者存储的形式。

在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)**、实例数据(Instance Data)、**对齐填充(Padding)。
在这里插入图片描述



  • mark-word:对象标记字段占4个字节,用于存储一些列的标记位,比如:哈希值、轻量级锁的标记位,偏向锁标记位、分代年龄等。



  • Klass Pointer:Class对象的类型指针,Jdk1.8默认开启指针压缩后为4字节,关闭指针压缩( &#8211; XX:-UseCompressedOops)后,长度为8字节。其指向的位置是对象对应的Class对象(其对应的元数据对象)的内存地址。



  • 对象实际数据:包括对象的所有成员变量,大小由各个成员变量决定,比如:byte占1个字节8比特位、int占4个字节32比特位。



  • 对齐:最后这段空间补全并非必须,仅仅为了起到占位符的作用。由于HotSpot虚拟机的内存管理系统要求对象起始地址必须是8字节的整数倍,所以对象头正好是8字节的倍数。因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。




通过ClassLayout打印对象头

我们可以通过打印类的对象头信息清晰的了解他的内存布局。



  • 首先引入相关依赖:



org.openjdk.jol
jol-core
0.9



  • 编写测试代码,在不加锁的情况下,对象头的信息打印:

public static void main(String[] args) {
//构建对象实力
ClassLayoutDemo classLayoutDemo = new ClassLayoutDemo();
System.out.println(ClassLayout.parseInstance(classLayoutDemo).toPrintable());
}


  • 输出内容:x

com.example.javastudy.concurrentDemo.v2.ClassLayoutDemo 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)

//kiss pointer
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

Synchronized 锁升级

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

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

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


流程



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



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



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



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



  • 升级到重量级锁,向操作系统申请资源, linux Mutex,然后线程被挂起进入到等待队列;性能消耗比较大。



下图为锁在JVM中的相关信息。
在这里插入图片描述


轻量级锁的获取及原理

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

public class LockDemo {
Object o = new Object();
public static void main(String[] args) {
LockDemo demo = new LockDemo();
//加锁之前
System.out.println(ClassLayout.parseInstance(demo).toPrintable());
System.out.println("加锁之后");
//如果由其他线程进入到下面的同步块,则先自旋
//CAS()保证数据操作的原子性
synchronized (demo) { //轻量级锁
System.out.println(ClassLayout.parseInstance(demo).toPrintable());
}
}
}

// 在未加锁之前,对象头中的第一个字节最后三位为 [001], 其中最后两位 [01]表示无锁,第一位[0]也 表示无锁
com.example.javastudy.concurrentDemo.v2.LockDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (0000000100000000 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 LockDemo.o (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
加锁之后

// 下面部分是加锁之后的对象布局变化
// 其中在前4个字节中,第一个字节最后三位都是[000], 后两位00表示轻量级锁,第一位为[0],表示当 前不是偏向锁状态。
com.example.javastudy.concurrentDemo.v2.LockDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) e8 f5 4d 03 (1110100011110101 01001101 00000011) (55440872)
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 LockDemo.o (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

这里很多人会有疑惑,上文不是说锁的升级是基于线程竞争情况,来实现从偏向锁到轻量级锁再到重量级锁的升级的吗?可是为什么这里明明没有竞争,它的锁的标记是轻量级锁呢?


偏向锁的获取及原理

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

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

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


-XX:BiasedLockingStartupDelay=0


在这里插入图片描述
设置完参数后再次运行上文代码。

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


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


com.example.javastudy.concurrentDemo.v2.LockDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (0000010100000000 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 LockDemo.o (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
加锁之后

com.example.javastudy.concurrentDemo.v2.LockDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 e8 09 03 (0000010111101000 00001001 00000011) (50980869)
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 LockDemo.o (object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

重量级锁的获取

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

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

public class HeightLockDemo {
public static void main(String[] args) {
HeightLockDemo demo = new HeightLockDemo();
new Thread(()->{
synchronized (demo){
System.out.println(" t1 lock");
System.out.println(ClassLayout.parseInstance(demo).toPrintable());
}
}).start();
synchronized (demo){
System.out.println(" t2 lock");
System.out.println(ClassLayout.parseInstance(demo).toPrintable());
}
}
}

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

t2 lock

com.example.javastudy.concurrentDemo.v2.HeightLockDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 6a 45 e5 1c (01101010 0100010111100101 00011100) (484787562)
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

com.example.javastudy.concurrentDemo.v2.HeightLockDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 6a 45 e5 1c (01101010 0100010111100101 00011100) (484787562)
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

CAS机制

我们在进行以下操作必须是原子的:



  • 修改锁的标记

  • 修改线程指针的指向

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



  • Compare and swap

  • Compare and exchange

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

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

如果 V=E 那么则更新变量V并设置值为N;

如果不等则不更新变量V并返回false。


类似乐观锁:随着Version的变化去判断数据是否为最新数据

CAS的返回值:



  • true:修改成功

  • false:修改失败

//自旋锁
for(;;){
//condition(自旋次数)

if(cas){ // true ->
//只有一个线程能够进来
//更改标记
break;
}
}

竞争非常大的时候,会不断循环,造成一定的性能问题。

这里面保证原子性还是需要依赖锁,这边使用的是CPU层面的锁。


这是AtomicInteger类中getAndSet方法确保原子性的操作,就是通过这种自旋锁的形式。

public final int getAndSetInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var4));
return var5;
}


推荐阅读
author-avatar
谢莹UiK_1928
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有