本篇 [并发与多线程系列] 的第二篇,对应Java知识体系脑图中的 并发与多线程 模块。
这一系列将对Java中并发与多线程的内容来展开。
synchronize与volatile
- synchronize与volatile
-
- Synchronize
-
- Java中的对象结构
- Synchronized的实现原理
- 锁的优化
-
- 锁升级
- 锁升级的过程
- 自适应自旋锁
- 锁粗化
- 锁消除
- Volatile
-
- Java内存模型(JMM)
- 主内存与工作内存
- 内存间交互操作
- volatile关键字
-
- volatile底层实现原理
synchronize与volatile
在Java的并发中,synchronize与volatile关键字出现的评率极高,它们也存在不同的效果。
Synchronize
传统的线程安全方式是用synchronized关键字实现。synchronized是Java中的关键字,是一种同步锁。它修饰的对象有以下几种:
- 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括 {}括起来的代码,作用的对象是调用这个代码块的对象。
- 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象。
- 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象。
- 修饰一个类,其作用的范围是synchronized后面括 括起来的部分,作用主的对象是这个类的所有对象。
注意:
-
使用synchronized修饰非静态方法或者使用synchronized修饰代码块时制定的为实例对象时,同一个类的不同对象拥有自己的锁,因此不会相互阻塞。
-
使用synchronized修饰类和对象时,由于类对象和实例对象分别拥有自己的监视器锁,因此不会相互阻塞。
-
使用使用synchronized修饰实例对象时,如果一个线程正在访问实例对象的一个synchronized方法时,其它线程不仅不能访问该synchronized方法,该对象的其它synchronized方法也不能访问,因为一个对象只有一个监视器锁对象,但是其它线程可以访问该对象的 非 方法。
-
线程A访问实例对象的 非 方法时,线程B也可以同时访问实例对象的方法,因为前者获取的是实例对象的监视器锁,而后者获取的是类对象的监视器锁,两者不存在互斥关系。
Java中的对象结构
要了解Synchronized的实现原理,要先介绍下对象头。
首先,我们要知道Java对象在内存中的布局:
已知对象是存放在堆内存中的,对象大致可以分为三个部分,分别是对象头、实例变量和填充字节。
- 对象头主要是由MarkWord和Klass Point(类型指针)组成,其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据。如果对象是数组对象,那么对象头占用3个字宽(Word),如果对象是非数组对象,那么对象头占用2个字宽。(1word = 2 Byte = 16 bit)。
- 实例变量存储的是对象的属性信息,包括父类的属性信息,按照4字节对齐。
- 当线程处于就绪状态,准备进入运行状态时,需要获取实例对象的监视器(monitor)锁,当获得了监视器后就可以进入运行状态,执行方法了。此时,ObjectMonitor对象指向了当前线程,_owner有且只会持有一个线程。
- 当线程进入等待、超时等待、阻塞状态,或者说线程运行完毕进入终止状态,则释放实例对象的监视器(monitor)锁。
- Synchronized修饰代码块
- Synchronized修饰方法
- 偏向锁
- 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态。
- 撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态。
- 轻量级锁(自旋锁)
- 重量级锁
- 它怎么做呢/strong>
- 8个指令之间的规则:
- read 和 load、store 和 write 必须成对使用。
- 不允许线程丢弃它最近的assign操作,即变量在工作内存中修改了之后必须同步回主存。
- 同步回主存的变量必须先经过assign操作。
- 一个新的变量只能在主存中诞生,工作内存中不能初始化变量,也不能直接使用未被初始化的变量。即在use 或 store操作之前,必须load 或 assign。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,该条线程可以重复进行多次lock操作。但是lock和unlock次数必须相等,才能解锁。
- 如果对一个变量进行lock操作,则会情况工作内存中该变量的值,若要使用需重新执行load操作。
- 变量没有执行过lock就不能执行unlock操作,也不允许去unlock其他线程锁住的变量。
- 对一个变量进行unlock操作前,必须把它同步回主存中(执行store、wirte操作)。
-
可见性。 关键字修饰的变量在每次使用前都会刷新,因此执行引擎总是能看到一致的数据。
-
禁止指令重排。在执行过程中多执行了“”操作,相当于一个内存屏障(Memory Barrier 或 Memory Fence),屏障后的指令不能被重排序到屏障之前。多个CPU下才需要指令屏障来保持代码顺序。
-
不保证原子性,不是线程安全的。可以使用synchronized关键字、java.util.concurrent下的lock锁、java.util.concurrent.atomic 下的原子类来保证原子性。
结合线程状态来说明一下Synchronized的实现原理。
在JVM规范里可以看到,不管是方法同步还是代码块同步都是基于进入和退出monitor对象来实现,然而二者在具体实现上又存在很大的区别。
Synchronized代码块同步在需要同步的代码块开始的位置插入monitorentry指令,在同步结束的位置或者异常出现的位置插入monitorexit指令;JVM要保证monitorentry和monitorexit都是成对出现的,任何对象都有一个monitor与之对应,当这个对象的monitor被持有以后,它将处于锁定状态。
Synchronized方法同步不再是通过插入monitorentry和monitorexit指令实现,而是由方法调用指令来读取运行时常量池中的ACC_SYNCHRONIZED标志隐式实现的。
如果方法表结构(method_info Structure)中的ACC_SYNCHRONIZED标志被设置,那么线程在执行方法前会先去获取对象的monitor对象,如果获取成功则执行方法代码,执行完毕后释放monitor对象,如果monitor对象已经被其它线程获取,那么当前线程被阻塞。
简单来说就是有Synchronized关键字修饰的方法,在其方法表结构中设置了ACC_SYNCHRONIZED标志,用来标识需要调用指令获取监视器(monitor)锁。方法执行完毕之后就释放监视器(monitor)锁。
锁的优化
锁升级
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS(CompareAndSet 比较并交换)操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
偏向锁的取消:
偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使用-XX:BiasedLockingStartUpDelay=0;
如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置。
偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,就算线程执行完毕也不会释放锁,需要等待其他线程来竞争。
偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
重量级锁通过对象内部的监视器(monitor)实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。重量级锁就是Synchronized锁。实现原理是mutex(互斥),任一时刻,只能有一个线程访问该对象。
注意:为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;偏向锁升级为轻量级锁也不能再降级为偏向锁。一句话就是锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。
自适应自旋锁
JDK 1.6 引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
线程如果自旋成功了,那么下次自旋的次数会更多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
锁粗化
锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
按理来说,同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。因此有了锁粗化。
锁消除
Java虚拟机在JIT编译时通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
为了提高热点代码的执行效率,在运行时会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,这就是即时编译。
Volatile
要理解volatile关键字的原理,那就不得不提Java内存模型(Java Memory Model,JMM)。
Java内存模型(JMM)
Java虚拟机(JVM)规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致性的内存访问效果。
JMM是一种规范性协议,即基于缓存一致性的协议,用于定义数据读写的规则。
缓存一致性原理可参考博主之前的博客Java内存模型(JMM)与线程
主内存与工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则。
此处的变量(Variables)是指线程共享的元素,包括实例字段、静态字段 和 构成数组对象的元素。
不包括局部变量与方法参数,它们是线程私有的。
JMM规定了所有变量都存储在主内存(Main Memory)中,每个线程中还有自己的工作内存(Working Memory)。
此处主内存与物理硬件的主内存虽然同名但不是一个概念,JMM中的主内存是JVM内存的一部分。且此处的主内存、工作内存与JVM中的堆内存、栈内存等也是没有关系的,划分的层次不同。
线程可看做处理器,线程中的工作内存可看做高速缓存。
线程、工作内存、主内存的交互关系如图:
volatile关键字
关键字是 Java 虚拟机提供的最轻量级的同步机制。
关键字的特性:
volatile底层实现原理
在说这个问题之前,我们先看看CPU是如何执行Java代码的。
文章知识点与官方知识档案匹配,可进一步学习相关知识Java技能树首页概览92925 人正在系统学习中
声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!