并发编程—带你深入理解 synchronized 原理

目录

1、基本使用

2、同步原理

2.1、同步代码块

2.2、同步方法

3、实现原理

3.1、Java对象头

3.2、Mark Word与线程中的Lock Record

4、锁的优化

4.1、自旋锁与

4.2、自适应自旋锁

4.3、锁消除

4.4、锁粗化

4.5、轻量级锁

4.5.1、加锁过程

4.5.2、解锁过程

4.6、偏向锁

4.6.1、偏向锁撤销

4.6.2、偏向锁关闭


1、基本使用

1.1、 synchronized的主要作用有如下三方面

1、原子性:确保线程互斥的访问代码块;

2、可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的“对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量钱,需要重新从主内存中load操作或者assign操作初始化变量”来保证;

3、有序性:有效解决重排序问题,即“一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”

1.2、synchronized 的三种使用方式:

1、作用在实例方法上,此时锁是当前实例对象。

2、作用在静态方法上,此时锁是当前类的Class对象。

3、作用在代码块上, 此时锁是Synchronized括号中指定的对象。

2、同步原理

synchronized 是在软件层面依赖JVM。当一个线程访问同步方法或者同步代码块时,首先需要得到锁才能执行同步代码,当退出或者抛出异常时都必须释放锁。但synchronized作用在方法或者代码的实现是不同的,具体实现机制如下:

2.1、同步代码块

同步代码块是在编译时把指令 monitorenter和monitorext指令分别加入到同步代码块的开始位置和结束位置。

1)、monitorenter:每个对象都是一个监视器(monitor)。当monitor 被占用是就会处于锁定状态,线程 执行monitorenter指令尝试获取monitor的所有权,过程如下:

1、如果monitor的进入数为0,则该线程进入 monitor,然后将进入数设置为1,该线程即为 monitor的所有者;

2、如果当前线程已经占有了改monitor,只是重新进入,则进入monitor的进入数加 1;

3、如果monitor已被其他线程所占有,则该线程进入阻塞状态,只到占有monitor的线程释放,并且monitor的进入数为0,再重新获取monitor的所有权。

2)、monitorexit: 执行monitorexit的线程必须是objectref所对应的monitor所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那边线程对出monitor,不再占有此monitor。其他被这个monitor阻塞的线程会尝试去获取这个monitor的所有权。

2.2、同步方法

方法同步在编译时没有插入 monitorenter 和monitorexit 指令来完成,只是相对于其他方法,其常量池中多了 ACC_SYNCHRONIZED标识符。JVM是根据改标识符来实现方法同步的:

当方法调用时,调用指令会检查方法的ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功后才执行方法体,方法执行完成后释放monitor。在方法执行其他,其他任何线程都无法在获取同一个monitor对象。

3、实现原理

3.1、Java对象头

在JVM中,对象在内存中的分布分为三个区域:对象头、实例数据和对齐填充。

1、实例数据:存放类的属性数据信息,包括父类的属性信息。

2、对齐填充:由于虚拟机要求对象起始地址必须为8字节的整数倍。填充数据不是必须的,仅仅是为了字节对齐;

3、对象头:Java对象头默认在2个字宽,如果是数组则为3个字宽,第三个用了存储数组的长度。

Synchronized用的锁就是存储在Java对象头里的, 虚拟机的对象头主要包括两部分:Mark Word(标记字段)、Class Pointer(类型指针)。

Mark Word 主要用于存储对象自身运行时数据,如:哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

1、无锁状态下的结构

锁状态

25bit

4bit

1bit是否是偏向锁

2bit所标志位

无锁状态

对象的hashCode

对象分代年龄

0

01

2、Mark Word 的状态变化

 

3.2、Mark Word与线程中的Lock Record

在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的所标志位是“01”,则虚拟机首先在当前线程的栈中创建一个称之为 ”锁记录(Lock Record)“的空间,用于存储所对象的Mark Word的拷贝。

Lock Record 是线程私有的数据结构,每个线程都有一个可用的Lock Record列表,同时还有一个全局的可用列表。每个被锁住的对象Mark Word都会和一个Lock Record 关联(对象头的Mark Word中的 Lock word 指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放当前线程所拥有的锁的地址。

4、锁的优化

从JDK6开始,就对synchronized的实现机制进行了较大的调整,加入了自旋锁、自适应自旋锁、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略,这些技术都是为了在线程之间更高效的共享数据及解决竞争问题,从而提高程序的执行效率。

锁主要存在四种状态,它们依次为:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。它们是上一次升级的,锁状态只能是从低到高依次升级,而不能降级。

4.1、自旋锁与

在互斥同步执行代码时,对性能影响最大的就是阻塞的实现,挂起线程和恢复线程的操作都需要有用户态转到内核态来完成,这些操作给Java虚拟机的并发性能带来了很大的压力。同时,虚拟机的开发团队注意到在许多应用上,共享数据的锁状态只会持续很短的一段时间,为了这段时间而去挂起和恢复线程并不值得。所以我们可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个空循环(即自旋),这项技术就是所谓的自旋锁。

自旋锁在JDK6中是默认开启了。自旋等待不能代替阻塞,且不说对处理器的要求,自旋等待本身虽然避免了线程切换的开销,但它是占用了处理器时间,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只能白白消耗处理器资源,而不会做任何有价值的工作,就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定次数或时间仍然没有成功获取锁,就应当使用传统的挂起线程。自旋次数默认值为10次,可以通过参数 -XX:PreBlockSpin来自行更改。

4.2、自适应自旋锁

在JDK6中对自旋锁的优化,引入了自适应自旋。自适应意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有这的状态来决定的。如果在同一个所对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间。另一方面,如果对于某个锁,自旋很少成功获得锁,那在以后要获取这个锁时就可能省掉了自旋过程,直接阻塞,以避免浪费处理器资源。

有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况就会越来越精准,虚拟机也就会变得越来越聪明。

4.3、锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据来源于逃逸分析的数据支撑,如果判断到一段代码中,在对上的所有数据都不会逃逸出去被其他线程访问到,那边就可以把他们当作栈上的数据对待,认为他们是线程私有的,自然就无需在进行同步加锁。

都知道在java中有好多工具类都是线程安全的,比如StringBuffer、Vector、HashTable。当他们在方法中作为局部变量使用时,他们就不会逃逸出去,这样在JVM的即时编译器就会去除同步加锁。如下所示:

   
 //对Vector中添加数据
 public void add(){
    Vector<String> vector = new Vector<>();
    for (int i = 0; i < 100; i++){
         vector.add(i + "");
     }
    System.out.println(vector);
 }

 Vector 的 add 方法上是添加了 synchronized 的同步方法,如下所示:

    public synchronized boolean add(E e) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = e;
        return true;
    }

 在运行上面代码时,JVM就会把 vector.add方法中的加锁操作消除掉。

4.4、锁粗化

原则上,在编写代码时,总是推荐将同步块的作用域范围限制的尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能的变少,即使存在锁竞争,等待锁的线程也尽可能快的拿到锁。

但如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环中,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

4.5、轻量级锁

轻量级锁是JDK6加入的新型锁机制,“轻量级”是相对于使用操作系统互斥量来实现传统锁而言的,因此传统的锁机制被称为“重量级”锁。轻量级锁并不是用来代替重量级锁的,他设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

4.5.1、加锁过程

1、在代码即将 任务同步块的时候,如果此时同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。

2、把当前对象的Mark Word 拷贝到 Lock Record 中。

3、然后,虚拟机通过CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果更新成功来了,执行(4)步,否则执行 (5)步。

4、如果更新成功了,即代表该对象拥有了这个对象的锁,并把对象 Mark Word的所标志位变为“00”,此时表示对象处于轻量级锁状态。

5、如果更新失败,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是说明当前线程已经拥有了此对象的锁,直接进入同步块执行即可,否则说明此对象锁已经被其他线程抢占了。如果出现两个以上的线程争用同一个锁的情况,那么轻量级锁不再有效,必须膨胀为重量级锁,锁标志变为“10”。

4.5.2、解锁过程

解锁过程也是通过CAS操作来完成的,其步骤如下:

1、如果对象的Mark Word 仍然指向线程锁记录,就使用CAS操作吧当前对象的Mark Word和线程中复制的Mark Word替换回来。

2、如果替换成功,那么整个同步过程就算执行完成了。

3、如果替换失败,则说明其他线程尝试过获取该锁,就需要释放锁的同时,唤醒被挂起的线程。

轻量级锁的依据是“对于绝大部分的锁,在整个同步周期呢都是不存在竞争的”这一前提。如果没有竞争,轻量级锁通过CAS操作成功避免了使用互斥量的开销;但是如果确实存在竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在竞争的情况下,轻量级锁反而比重量级锁更慢。

4.6、偏向锁

偏向锁也是JDK6的重要引进,因为在大多数情况下,锁不仅不会存在多线程竞争,而且总是有同一线程多次获得,为了线程获得锁的代价更低,从而引入了偏向锁。

当一个线程访问同步块并获取锁是,会在对象头和栈帧中国的锁记录里存储锁偏向的线程ID,以后该线程再进入和退出同步块是,不需要进行CAS操作来加锁和解锁,只需判断一下对象头的Mark Word里是否存储这指向当前线程的偏向锁。

4.6.1、偏向锁撤销

偏向锁使用了一种等待竞争出现才释放锁的机制,所以当其他线程竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁获得和撤销流程

4.6.2、偏向锁关闭

偏向锁在JDK6以后的版本中是默认开启的,如果确定应用程序所有的锁通常情况下都是处于竞争状态,可以通过JVM参数关闭偏向锁。-XX:-UseBiasedLocking=false

参考文档

1、《Java并发编程艺术》——方腾飞 魏鹏 程晓明

2、《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》——周志明

3、深入分析Synchronized原理(阿里面试题)

 

此文档只是作为自己学习笔记记录,方便知识回顾,有不当支出,还望斧正。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值