Java内存可见性,Java内存模型,synchronized原
JVM内存结构、Java对象模型和Java内存模型分别解释:
-
JVM内存结构
由Java虚拟机规范定义。描述的是Java程序执行过程中,由JVM管理的不同数据区域。 各个区域有其特定的功能。
内存.png
JDK1.8之前使用永久代实现方法区,1.8之后改用本地内存中实现的元空间代替,把1.8之前的永久代剩余内容全部转移到元空间中.
-
Java对象模型
而Java对象在JVM中的存储也是有一定的结构的。于Java对象自 身的存储模型称之为Java对象模型。
HotSpot虚拟机中(Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围广的Java虚拟机),设 计了一个OOP-Klass Model。OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描 述对象实例的具体类型。
每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个 instanceKlass 对象,保存在方法区, 用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个 instanceOopDesc 对象,这个对象中包含了对象头以及实例数据。
内存.png
-
Java内存模型
Java内存模型就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程 序在各种平台下对内存的访问都能保证效果一致的机制及规范。参考:https://www.hollischuang.com/archives/2550。
JMM并不像JVM内存结构一 样是真实存在的。他只是一个抽象的概念。JSR-133: Java Memory Model and Thread Specification中 描述了,JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写 入时对另一个线程是可见的。
简单总结下,Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过 程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一 系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、 synchronized等关键字。
- 第一条关于线程与主内存:线程对共享变量的所有操作都必须在自己的工作内存(本地内存)中进行, 不能直接从主内存中读写
- 第二条关于线程间本地内存:不同线程之间无法直接访问其他线程本地内存中的变量,线程间变量值的 传递需要经过主内存来完成。
- 主内存 主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员 变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于 是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。
- 本地内存 主要存储当前方法的所有本地变量信息(本地内存中存储着主内存中的变量副本拷贝),每个线程只 能访问自己的本地内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同 一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码 行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互 访问工作内存,因此存储在工作内存的数据不存在线程安全问题
小结:
JVM内存结构,和Java虚拟机的运行时区域有关。 Java对象模型,和Java对象在虚拟机中的表现形式有 关。 Java内存模型,和Java的并发编程有关。
内存可见:
-
内存可见性介绍:
- 可见性: 一个线程对共享变量值的修改,能够及时的被其他线程看到
- 共享变量: 如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量
线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:
- 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
-
然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量
内存.png
如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线 程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也 变为了 1。
内存可见性问题
public class Demo1Jmm {
public static void main(String[] args) throws InterruptedException {
JmmDemo demo = new JmmDemo();
Thread t = new Thread(demo);
t.start();
Thread.sleep(100);
demo.flag = false;
System.out.println("已经修改为false"); System.out.println(demo.flag); }
static class JmmDemo implements Runnable { public boolean flag = true;
public void run() {
System.out.println("子线程执行。。。");
while (flag) {
}
System.out.println("子线程结束。。。");
}
解决内存可见性问题:
- 加synchronized关键字
synchronized可以保证方法或者代码块在运行时,同一时刻只有一个线程执行synchronized声明的代 码块。还可以保证共享变量的内存可见性。同一时刻只有一个线程执行,这部分代码块的重排序也不会 影响其执行结果。也就是说使用了synchronized可以保证并发的原子性,可见性,有序性。
JMM关于synchronized的两条规定:
线程解锁前(退出同步代码块时):必须把自己工作内存中共享变量的新值刷新到主内存中
线程加锁时(进入同步代码块时):将清空本地内存中共享变量的值,从而使用共享变量时需要从主内 存中重新读取新的值(加锁与解锁是同一把锁)
while (flag) {
synchronized (this) {
}
}
- synchronized实现可见性的过程
- 获得互斥锁(同步获取锁)
- 清空本地内存
- 从主内存拷贝变量的新副本到本地内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
synchronized的同步可以解决原子性、可见性和有序性的问题,那是如何实现同步的呢?
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:
- 普通同步方法,锁是当前实例对象this (当前对象)
- 静态同步方法,锁是当前类的class对象 (当前类)
- 同步方法块,锁是括号里面的对象
当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要 释放锁。
-
synchronized的实现原理 :
同步操作主要是monitorenter和monitorexit这两个jvm指令实现的
在cmd命令行执行javac编译和javap -c Java 字节码的指令
jvm.png -
monitorenter和monitorexit这两个jvm指令实现锁的使用,主要是基于 Mark Word和、monitor。
- Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
- Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,
-
Mark Word是实现轻量级锁和偏向锁的关键, 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。下图是Java对象头的存储结构(32位虚拟机):
锁.png -
monitor
是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下:
锁.png
其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
jvm.png
锁优化
synchronized是重量级锁,效率不高。但在jdk 1.6中对synchronize的实现进行了各种优化,使得它显 得不是那么重了。jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、 偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四中状态,依次是:
无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞 争的激烈而逐渐升级。
-
注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
自旋锁
自旋锁的应用场景:
- 线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的,所以引入自旋锁。
若一个线程等待获取锁对象所持续的时间非常短,这时适合使用自旋锁。所谓自旋锁,就是等待锁的线程并不进入阻塞状态,而是执行一个无意义的循环。在循环结束后查看锁是否已经被释放,若已经释放则直接进入执行状态。因为长时间无意义循环也会大量浪费系统资源,因此自旋锁适用于间隔时间短的加锁场景 - 自旋等待不能替代阻塞,自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。 同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;
- 于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。
自适应自旋锁 (智能)
- JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它 是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
- 线程如果自旋成功了, 那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成 功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么 在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
锁消除
- 在有些情况下,JVM 检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数 据支持。
- 那为什么还要加锁呢?
是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时 候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:
锁粗化
- 在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步, 这样做的目的是为了使需要同步的操作量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性 能损耗,所以引入锁粗化的概念。
- 锁粗化概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。,JVM检测到对同一个对象(vector)连续加锁、解 锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外
偏向锁
- 轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。而偏向锁只需要检查是否为偏向锁、锁标识 为以及ThreadID即可,可以减少不必要的CAS操作。
轻量级锁
- 当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻 量级锁。轻量级锁主要使用CAS进行原子操作。
- 但是对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争 的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻 量级锁比重量级锁更慢。
轻量锁与偏向锁的不同:
轻量锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁
轻量锁每次进入/退出同步块都需要CAS更新对象头
争夺轻量级锁失败时,自旋尝试抢占锁
可以看到轻量锁适合在竞争情况下使用,其自旋锁可以保证响应速度快,但自旋操作会占用CPU,所以一些计算时间长的操作不适合使用轻量级锁。
==>可以认为 自旋锁 是轻量锁执行中的一部分
重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的 Mutex Lock(互斥锁)实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本 非常高。
锁.png