- 学·问
- 帖子详情
并发编程4-关键字synchronized详解
豆斗逗逗
发表于2022年10月27日
<p>带着疑问去学习,https://www.pdai.tech/md/java/thread/java-thread-x-key-synchronized.html<br ></p><p> 1.Synchronized可以作用在哪里? </p><p> 分别通过对象锁和类锁进行举例。</p><p> 2.Synchronized本质上是通过什么保证线程安全的? </p><p> 分三个方面回答:加锁和释放锁的原理,可重入原理(当线程拥有Monitor权限时,重新进入将会将),保证可见性原理。</p><p> </p><p> 3.Synchronized修饰的方法在抛出异常时,会释放锁吗?</p><p> Synchronized修饰的方法正常执行完毕和异常结束都会释放锁</p><p> </p><p> 4.不同的JDK中对Synchronized有何优化? </p><p> jdk1.6版本后,锁的升级过程 无锁---偏向锁---轻量级锁---重量级锁</p><p> </p><p> 5.什么是锁的升级和降级? 什么是JVM里的偏向锁、轻量级锁、重量级锁?</p><p> </p><p> 6.Synchronized由什么样的缺陷? Java Lock是怎么弥补这些缺陷的。</p><p> </p><p> </p><p> 7.使用Synchronized有哪些要注意的? </p><p> 锁对象不能为空,因为锁的信息都保存在对象头里 </p><p> 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错 </p><p> 避免死锁 </p><p> 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错 </p><p> synchronized是公平锁吗? </p><p> synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象。</p><p> </p><p> 如何通过对象锁实现跨方法加锁?</p><p> 通过 sun.misc.Unsafe#monitorEnter sun.misc.Unsafe#monitorExit 进行实现;不推荐使用</p><p> </p><p>问题一:Synchronized的使用 </p><p> 对象锁 代码块形式:手动指定锁定对象,也可是是this,也可以是自定义的锁 方法锁形式:synchronized修饰普通方法,锁对象默认为this </p><p> 类锁 synchronize修饰静态方法 synchronized指定锁对象为Class对象 </p><p> </p><p> </p><p> </p><p>在应用Sychronized关键字时需要把握如下注意点: </p><p> 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待; </p><p> 每个实例都对应有自己的一把锁(this),不同实例之间互不影响;例外:锁对象是*.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁 </p><p> synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁</p><p><br ></p><p>synchronized可以保证方法或者代码块在运行时,同一时刻只有一个线程可以进入到临界区(互斥),同时它还可以保证共享变量的内存可见性 原子性 有序性</p><p>Java中每一个非null对象都可以作为锁(都会在JVM 内部维护一个与之对应Monitor对象(管程对象)),这是synchronized实现同步的基础。</p><p><br ></p><p>synchronized 常见的三种使用方法如下:</p><p> 普通同步方法,锁是当前实例对象,</p><p> 同步方法块, 锁是括号里面的对象</p><p> 静态同步方法,锁是当前类的class对象</p><p> 例如:案例一</p><p> public Class MyClass { </p><p> public void synchronized method1() { //对象锁</p><p> // ... </p><p> }</p><p> public static void synchronized method2() { // 类锁</p><p> // ... </p><p> } </p><p> } </p><p> 等价于:</p><p> public class MyClass { </p><p> public void method1() { </p><p> synchronized(this) { </p><p> // ... </p><p> } </p><p> }</p><p> public static void method2() { </p><p> synchronized(MyClass.class) { </p><p> // ... </p><p> } </p><p> } </p><p> } </p><p> </p><p>问题2:Synchronized原理分析 加锁和释放锁的原理 ,可重入原理 </p><p><br ></p><p> 首先明白synchronized 锁的是什么? 锁的是对象。比如上面案例一中的method1(),锁住的是调用该方法的this对象。</p><p> 锁对象是如何实现的呢?通过对象内部的监视器锁Monitor实现,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低;</p><p><br ></p><p> JVM内置锁通过使用synchronized实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现代码同步;ObjectMonitor 类的结构体</p><p> ObjectMonitor::ObjectMonitor() { </p><p> _header = NULL; // 对象头</p><p> _count = 0; // 记录加锁的次数,锁重入时用到</p><p> _waiters = 0, // 当前有多少处于wait状态的Thread</p><p> _recursions = 0; // 线程的重入次数</p><p> _object = NULL; </p><p> _owner = NULL; // 标识拥有该monitor的线程对象thread</p><p> _WaitSet = NULL; // 处于wait状态的的线程会被记录到_waitSet中 等待线程组成的双向循环链表,_WaitSet是第一个节点 同步阻塞队列</p><p> _WaitSetLock = 0 ; </p><p> _Responsible = NULL ; </p><p> _succ = NULL ; </p><p> _cxq = NULL ; //多线程竞争锁进入时的单向链表</p><p> FreeNext = NULL ; </p><p> _EntryList = NULL ; //_owner从该双向循环链表中唤醒线程结点,_EntryList是第一个节点</p><p> _SpinFreq = 0 ; </p><p> _SpinClock = 0 ; </p><p> OwnerIsThread = 0 ; </p><p> } </p><p> 反编译出字节码文件: monitorEnter和monitorExit 关键字</p><p> 每个对象有一个对象监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,</p><p> 加锁和释放锁的过程以及可重入原理 </p><p> 如果monitor的进入数为0,则该线程进入monitor,然后将进入数_count设置为1,该线程即为monitor的所有者。_owner</p><p> 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数再加1。(可重入原理)</p><p> 如果其他线程已经占用了monitor,则该线程进入同步队列SynchronizedQueue 处于阻塞状态,直到monitor的进入数为0时,发送通知消息,等待的线程重新竞争,重新尝试获取monitor的所有权</p><p> 执行 monitorexit的线程必须是对应的monitor的所有者。</p><p> 指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。</p><p> 其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。</p><p><br ></p><p> 通过上面的描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,</p><p> 其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。</p><p><br ></p><p>问题3:通过上面的描述我们清楚了,synchronized加锁是加载对象上的,并且是通过监视器锁Monitor实现的。那对象是如何记录锁的状态的呢?这些信息是存储在哪里?</p><p> 先简单说下锁的状态有以下4种( 无锁 偏向锁 轻量级锁 重量级锁)</p><p> 对象是通过 对象头 记录锁状态</p><p><br ></p><p> 首先让我们先了解下对象的内存结构</p><p> 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等</p><p> 实际数据:即创建对象时,对象中成员变量,方法等</p><p> 对齐填充:对象的大小必须是8字节的整数倍</p><p><br ></p><p> 详细了解Java对象头</p><p> synchronized用的锁是存在Java对象头里的,那么什么是Java对象头呢?</p><p> Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)、数组长度</p><p> 其中Class Point是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,比如通过 Class.getClass() 获取Class 类型</p><p> </p><p> 详细了解 Mark Word</p><p> Mark Word 用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。所以下面将重点阐述。</p><p> Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。</p><p> Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),</p><p> 但是如果对象是数组类型,则需要三个机器码:因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。</p><p> Java对象头的存储结构(32位虚拟机):</p><p> 25bit 记录 对象哈希码(HashCode) 4bit 记录GC分代年龄 1bit记录是否是偏向锁 2bit记录锁的标志位</p><p><br ></p><p><br ></p><p>问题四:锁升级过程详解,锁升级经历了哪几种状态: 无锁 偏向锁 轻量级锁 重量级锁</p><p><br ></p><p> 详解 偏向锁 轻量级锁 自旋锁 自适应自旋锁 锁消除</p><p> 偏向锁 :偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段 适用于: 单独一个线程访问</p><p> 经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,</p><p> 因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时,同步数据等草走)的代价而引入偏向锁。(引入偏向锁的原因)</p><p> 偏向锁的核心思想是:</p><p> 如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构 是否是偏向锁标志位记录为1,</p><p> 当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果。 </p><p> </p><p> 但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,</p><p> 否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。</p><p> </p><p> 轻量级锁:倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的), 适用于:竞争不激烈,多个线程之间交替执行</p><p> 此时Mark Word 的结构也变为轻量级锁的结构。</p><p> 轻量级锁能够提升程序性能的依据是:“对绝大部分的锁,在整个同步周期内都不存在竞争”,(引入轻量级锁的原因)</p><p> 需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就 会导致轻量级锁膨胀为重量级锁。</p><p><br ></p><p> 自旋锁:轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。</p><p> 这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,(引入自旋锁的原因)</p><p> 毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对 比较长的时间,时间成本相对较高,</p><p> 因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),</p><p> 一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。</p><p> 如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,</p><p> 这种方式确实也是可以提升效率的。 最后没办法也就只能升级为重量级锁了。</p><p><br ></p><p> 自适应自旋锁 : 在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。(引入自适应自旋锁的原因)</p><p> 如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。</p><p> 相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。</p><p> 有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明</p><p><br ></p><p> 锁消除:消除锁是虚拟机另外一种锁的优化,这种优化更彻底,</p><p> Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,(引入锁消除的原因)</p><p> 通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间</p><p> </p><p> 锁粗化:</p><p> 如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能操作。例如</p><p> public static String test04(String s1, String s2, String s3) {</p><p> StringBuffer sb = new StringBuffer();</p><p> sb.append(s1);</p><p> sb.append(s2);</p><p> sb.append(s3);</p><p> return sb.toString();</p><p> }</p><p> </p><p> 在上述的连续append()操作中就属于这类情况。JVM会检测到这样一连串的操作都是对同一个对象加锁,那么JVM会将加锁同步的范围扩展(粗化)到整个一系列操作的 外部,使整个一连串的append()操作只需要加锁一次就可以了</p><p> </p><p> </p><p> </p><p> 锁的膨胀升级过程 </p><p> 锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。</p><p> 随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。下图为锁的升级全过程:</p><p> 注:其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。</p><p> JVM一般是这样使用锁和Mark Word的:</p><p> 1,当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。</p><p> 2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。</p><p> 3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。</p><p> 4,当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,</p><p> 就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。</p><p> 5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,</p><p> 同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。</p><p> 6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。</p><p> 7,自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。</p><p><br ></p><p> </p><p><br ></p><p>问题5:为什么需要进行锁优化?</p><p> Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。</p><p> 而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。</p><p> </p><p> </p><p> </p><p>问题6:再详解可重入原理:加锁次数计数器 什么是可重入?可重入锁? </p><p> 可重入:(来源于维基百科)若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执
1
回复