前言 在并发编程中存在线程安全问题,主要原因有:1.存在共享数据 2.多线程共同操作共享数据。入理关键字synchronized可以保证在同一时刻,并发编程只有一个线程可以执行某个方法或某个代码块,入理同时synchronized可以保证一个线程的并发编程变化可见(可见性),即可以代替volatile。入理 设计同步器的并发编程意义 多线程编程中,有可能会出现多个线程同时访问同一个共享、入理可变资源的并发编程情况,这个资源我们称之为 临界资源 。入理这种资源可能是并发编程:对象、变量、入理文件等。并发编程 引出的并发编程问题:由于线程执行的过程是不可控的,所以需要采用同步机制来协调对对象可变状态的访问。 如何解决线程并发安全问题? 实际上,所有的并发模式在解决线程安全问题时,采用的方案都是 序列化访问临界资源 。即在同一时刻,只能有一个线程访问临界资源,源码下载也称作 同步互斥访问 。 Java 中,提供了两种方式来实现同步互斥访问:synchronized 和 Lock 同步器的本质就是加锁。加锁目的:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同步互斥访问) 不过有一点需要区别的是:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,引出不具有共享性,不会导致线程安全问题。 synchronized 原理分析 synchronized 内在锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。 加锁的方式: 1.同步实例方法,锁是当前实例对象 synchronized修饰非静态方法 锁定的亿华云计算是该类的实例, 同一实例在多线程中调用才会触发同步锁定 所以多个被synchronized修饰的非静态方法在同一实例下 只能多线程同时调用一个。 2.同步类方法,锁是当前类对象 synchronized修饰静态方法 锁定的是类本身,而不是实例, 同一个类中的所有被synchronized修饰的静态方法, 只能多线程同时调用一个。 3.同步代码块,锁是括号里面的对象 synchronized块 直接锁定指定的对象,该对象在多个地方的同步锁定块,只能多线程同时执行其中一个。 synchronized底层原理 synchronized 是基于 JVM内置锁 实现,通过内部对象Monitor(监视器锁) 实现,基于进入与退出 Monitor 对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的 Mutex lock (互斥锁)实现,它是一个重量级锁性能较低。当然,JVM 内置锁在1.5之后版本做了大量的优化,网站模板如锁粗化(Lock Coarsening)、锁消除(Lock Eliminaction)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与 Lock 持平。 synchronized 关键字被编译成字节码后会被翻译成 monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。 示例: 反编译结果如下: Monitor 监视器锁 每个同步对象都有一个自己的 Monitor(监视器锁),加锁过程如下所示: 任何一个对象都有一个 Monitor 与之关联,当且一个 Monitor 被持有后,它将处于锁定状态。 Synchronized 在JVM里面的实现都是 基于进入和退出 **Monitor 对象 **来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的 MonitorEnter 和 MonitorExit 指令来实现。 通过上面两段描述,我们应该能很清楚地看出 Synchronized 的实现原理,Synchronized 的语义底层是通过一个 monitor 的对象来完成,其实 wait/notify 等方法也依赖于 monitor 对象,这就是为什么只有在同步的块或者方法中才能调用 wait/nofity 等方法,否则会抛出 java.lang.IllegalMonitorStateException 的异常原因。 示例:看一个同步方法 反编译结果: 从编译的结果来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池多了 ACC_SYNCHRONIZED 标识符。 JVM 就是根据该标识符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标识是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法获得同一个 monitor 对象。 两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是 JVM 通过调用操作系统的互斥原语mutex 来实现,被阻塞的线程会被挂起,等待重新调度,会导致“用户态和内核态”两个态直接来回切换,对性能有较大的影响。 什么是 Monitor ? 可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的 Monitor,每一个Java 对象都有成为 Monitor 的潜质,因为在Java的设计中,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者 Monitor锁。也就是通常说的 Synchronized 的对象锁,MarkWord 锁标识位为10,其中指针指向的是 Monitor 对象的起始地址。在 Java 虚拟机(HotSpot)中,Monitor 是由 ObjectMonitor 实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++实现的): ObjectMonitor 中有两个队列,_WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表(每个等待锁的线程都会被封装成 ObjectWaiter 对象),_owner 指向持有 ObjectWaiter 对象的线程,当多个线程同时访问一段同步代码时: 同时,Monitor 对象存在于每个Java对象的对象头 Mark Word 中(存储的指针的指向),Synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因,同时 notify/notifyAll/wait 等方法会使用到 Monitor 锁对象,所以必须在同步代码块中使用。监视器 Monitor 有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。 那么有个问题来了,我们知道 synchronized 加锁加在对象上,对象是如何记录锁状态的呢?答案是锁状态被记录在每个对象的对象头(Mark Word)中,下面一起来认识下对象的内存布局。 对象的内存布局 HostSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。 对象头 HotSpot 虚拟机的 对象头 包括两部分信息 第一部分是 “Mark Word”,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标识、线程持有的锁、偏向锁ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键。 这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。对象需要存储的运行时的数据很多,其实已经超出了 32、64 位Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率, Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的信息,它会根据对象的状态复用到自己的存储空间。 例如在32位的HotSpot虚拟机中对象未被锁定的状态下,Mark Word 的32位Bits空间中的: 但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java 对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块记录数组的长度。 对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化。变化状态如下: 32位虚拟机 64位虚拟机 现在我们虚拟机基本是64位的,而64位的对象头有点浪费空间,JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头的。 哪些信息会被压缩? 在Scott oaks写的《java性能权威指南》第八章8.22节提到了当heap size堆内存大于32GB是用不了压缩指针的,对象引用会额外占用20%左右的堆空间,也就意味着要38GB的内存才相当于开启了指针压缩的32GB堆空间。 这是为什么呢?看下面引用中的红字(来自openjdk wiki:https://wiki.openjdk.java.net/display/HotSpot/CompressedOops)。32bit最大寻址空间是4GB,开启了压缩指针之后呢,一个地址寻址不再是1byte,而是8byte,因为不管是32bit的机器还是64bit的机器,java对象都是8byte对齐的,而类是java中的基本单位,对应的堆内存中都是一个一个的对象。 Compressed oops represent managed pointers (in many but not all places in the JVM) as 32-bit values which must be scaled by a factor of 8 and added to a 64-bit base address to find the object they refer to. This allows applications to address up to four billion objects (not bytes), or a heap size of up to about 32Gb. At the same time, data structure compactness is competitive with ILP32 mode. 对象头分析工具 运行时对象头锁状态分析工具JOL,他是OpenJDK开源工具包,引入下方maven依赖 打印markword 锁的膨胀升级过程 锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。从JDK1.6中默认是开启偏向锁和轻量级锁的,可以通过 -XX:-UseBiasedLocking 来禁用偏向锁。下图为锁的升级全过程: 偏向锁 偏向锁 是Java 6 之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为来减少同一线程获取锁(会涉及到一些 CAS 操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争毕竟激烈的场合,偏向锁就失效了,因为这样的场合极有可能每次申请锁的线程都是不同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。 轻量级锁 倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时 Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是:“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。 自旋锁 轻量级锁失败后,虚拟机为了避免线程真实地操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100个循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没有办法也就只能升级为重量级锁了。 锁粗化 通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是在某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。 一种极端的情况如下: 上面的代码是有两块需要同步操作的,但在这两块需要同步操作的代码之间,需要做一些其它的工作,而这些工作只会花费很少的时间,那么我们就可以把这些工作代码放入锁内,将两个同步代码块合并成一个,以降低多次锁请求、同步、释放带来的系统性能消耗,合并后的代码如下: 注意:这样做是有前提的,就是中间不需要同步的代码能够很快速地完成,如果不需要同步的代码需要花很长时间,就会导致同步块的执行需要花费很长的时间,这样做也就不合理了。 另一种需要锁粗化的极端的情况是: 上面代码每次循环都会进行锁的请求、同步与释放,看起来貌似没什么问题,且在jdk内部会对这类代码锁的请求做一些优化,但是还不如把加锁代码写在循环体的外面,这样一次锁的请求就可以达到我们的要求,除非有特殊的需要:循环需要花很长时间,但其它线程等不起,要给它们执行的机会。 锁粗化后的代码如下: 锁消除 锁消除是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单立即为当某段代码即将第一次被执行时进行编译,又称为即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下 StringBuffer 的 append 是一个同步方法,但是在 add 方法中的 StringBuffer 属于一个局部变量,并且不会被其他线程所使用,因此 StringBuffer 不可能在共享资源竞争的情景,JVM 会自动将其锁消除。锁消除的依据是逃逸分析的数据支持。 锁消除,前提是java必须运行在 server模式(server模式会比client模式做更多的优化),同时必须开启逃逸分析 锁消除是发生在编译器级别的一种锁优化方式,有时候我们写的代码完全不需要加锁,却执行了加锁操作。比如,StringBuffer 类的 append 操作 从源码中可以看出,append 方法使用了 synchronized 关键字,它是线程安全的。但我们可能仅在线程内部把 StringBuffer 当作局部变量使用: 代码中 createStringBuffer 方法中的局部对象 sBuf ,就只在该方法内的作用域有效,不同线程同时调用 createStringBuffer() 方法时,都会创建不同的 sBuf 对象,因此此时的 append 操作若是使用同步操作,就是白白浪费系统资源。 这时我们可以通过编译器将其优化,将锁消除 ,前提是 Java 必须运行在 server 模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析: 逃逸分析:比如上面的代码,它要看 sBuf 是否可能逃出它的作用域?如果将 sBuf 作为方法的返回值进行返回,那么它在方法外部可能被当作一个全局对象使用,就有可能发生线程安全问题,这时就可以说 sBuf 这个对象发生逃逸了,因而不应将 append 操作的锁消除,但我们上面的代码没有发生锁逃逸,锁消除就可以带来一定的性能提升。 逃逸分析 使用逃逸分析,编译器可以对代码做如下优化: 是不是所有的对象和数组都会在堆内存分配空间?答案是:不一定 在Java代码运行时,通过 JVM 参数可以指定释放开启逃逸分析: 从JDK1.7 开始已经默认开启逃逸分析,如需关闭,需要指定 -XX:-DoEscapeAnalysis 逃逸分析案例:循环创建50W次 NiuhStudent 对象 第一种情况:关闭逃逸分析,同时调大堆空间,避免堆内GC的发生,如果有GC信息将会被打印出来 如下所示创建了50W个实例对象 第二种情况:开启逃逸分析 如下未创建50W个实例对象 PS:以上代码提交在 Github : https://github.com/Niuh-Study/niuh-juc-final.git