写文章是一个很累的过程,因为每展开一个点,它有可能是进入一个面的开始,有时候也会踌躇是将这个点一笔带过还是展开来讲,我尽量是希望我写的文章能够理顺一个脉络,能够将各个知识点有所串联,这样读者有时候在阅读其他文章的时候能够有自己的脉络将这些知识点一个一个串起来。有时笔力有限,知识面有限的时候,我可以依此去了解更多的东西,这是一个很高兴的过程。在这个过程中,又会看到其他作者的文章,也被很多优秀作者的文章所震撼,希望以后能写出越来越多被认可的好文章。
JVM内存模型(JMM)
JVM内存模型出现的原因:为了让java应用运行在各个机器上,不需要Java程序员像C语言程序员一样对硬件内存知之甚详,所以Sun公司推出了JVM虚拟机的内存模型,其中它一个作用就是隔离程序与实际硬件间的内存交互。Sun公司将虚拟机的空间自己做了一个规范,屏蔽了硬件上的内存概念,Java程序员开发时只需要了解JVM规范的内存模型即可。
JVM运行时数据区(6种)
-
程序计数器(The pc Register):线程独有,记录当前线程所执行的字节码的行号指示器(运行原理可看图灵机)
-
JVM栈(Java Virtual Machine Stacks):也称为虚拟机栈或简称栈,线程独有,每个线程独有,生命周期与当前线程一致。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量、操作栈、动态链接、方法返回地址等信息。每一个方法从调用到执行完毕的过程,就意味着一个栈帧在虚拟机栈中入栈到出栈的过程。
- 栈帧:一个非常重要的概念,是栈的操作单元,栈帧包括:局部变量表,操作数栈,动态链接,方法返回地址。
- 局部变量表(Local Variable Table):保存变量的数组,用于存放方法参数和方法内部定义的局部变量,包括基本类型和引用类型地址(所以面试时如果有面试官问对象的引用在哪里,可在此获得答案)。
- 操作数栈(Operand Stacks):可以理解为java虚拟机栈中的一个用于计算的临时数据存储区,通过后进先出 (LIFO)弹栈/压栈的方式访问。
- 动态链接(Dynamic Linking):在之前的JVM之我们的代码是如何被加载到JVM执行的?(一)文章中我们有读过class文件,其中class常量池(也称静态常量池)有符号引用的说法,而动态链接就是将符号引用解析为直接引用(加快程序访问速度)。
- 方法返回地址(return Address):一个方法执行后,有两种完成方式:1.正常调用完成(Normal Method Invocation Completion) ;2.异常调用完成(Abrupt Method Invocation Completion),这个方法返回地址则是存储方法执行返回的结果地址。
我们程序调用的方法就运行在JVM栈,由于是栈的形式,所以方法运行的特点是先入后出,如果单个线程请求的栈深度大于虚拟机允许的深度,例如一直做递归,只有方法入栈而没有弹栈,则会抛出StackOverflowError(栈溢出错误)。
-
堆(Heap):堆是所有线程之间共享的内存空间,也是运行时数据区最大的一块空间,GC操作经常出现在此处。
-
方法区(Method Area):方法区也是线程间共享的,它存储着每个类的结构,例如运行时常量池,类信息(类中方法,字段等),在逻辑上是属于堆(jdk7以后放在堆中,用元空间实现,jdk7以前由永久代实现,由于永久代不会产生GC回收内存,容易出现OOM。而元空间则跟物理内存挂钩,也可以被回收)。
-
运行时常量池(Run-Time Constant Pool):运行时常量池是从方法区分配出来的,是从class常量池转化过来的,class常量池里的方法和变量实际并非存储在class常量池中,class常量池只是保存了符号应用,而实际运行起来是,这些方法的引用将会存在运行时常量池中。class常量池是编译阶段的,而运行时常量池是运行阶段的。
-
本地方法栈(Native Method Stacks): 线程私有,用来支持native方法。和JVM栈的功能和特点类似,都是线程独有,不同的是本地方法栈是执行native方法,而native方法的实现是由不同的虚拟机各自实现的,我们一般也不会去接触。有些文章说本地方法栈调用的就是我们的方法,可能因为这个概念的名称容易混淆,其实是不准确的,即使HotSpot的虚拟机选择合并了JVM栈和本地方法栈(摘自《深入理解Java虚拟机》),这种说法也不准确。
关于上面的概念以及概念的实际意义如果展开来讲是非常繁杂丰富的,如果很多同学对上面的概念有疑惑,会考虑写一篇文章来尽量解答疑问。
Java内存模型(JMM)
Java内存模型是Java语言在多线程并发情况下对于共享变量读写(实际是共享变量对应的内存操作)的规范,主要是为了解决多线程可见性、原子性的问题,解决共享变量的多线程操作冲突问题。
上图是Java内存模型
硬件内存架构
虽然上面说了不用太了解硬件的内存架构,但是多了解一些,可以帮助我们更理解JVM虚拟机的设计初衷,以及给Java语言带来了什么影响。
上图是操作系统内存模型
可以看到两者是非常相似的,数据的交换和同步发生在主存(或者堆)
由JMM以及硬件内存架构引发的思考
在硬件架构中CPU内部空间的读写是远快于CPU外部的读写,但是由于CPU内部空间不会很大,只有计算时才会将数据读入到CPU内部空间,并且由于CPU很快,快于外部空间的读写。由于多核多线程的原因,势必会因为速度、多核CPU之间间的物理隔绝、多线程操作同一个数据,产生数据不同步的问题。而且由于CPU运行速度快,早已经把结果计算完,准备写入主存,但是由于外部空间读写慢,CPU不可能老是去等待外部空间完成任务,为了尽可能的利用CPU的计算能力,使用指令重排序的能力能够让CPU更高效工作。同样的线程也会出现这种问题,因为线程的执行是在CPU,不同的线程之间对共享变量的同步要求也很高,由以上将引发的一些常见问题:缓存一致性问题,指令重排序问题。
缓存一致性问题
每种内存的读写速度从下到少是越来越快的,在每个CPU上都有自己的寄存器,由于寄存器实在太快了,继而使用高速缓存来解决CPU内外部速度相差太大的问题。但是高速缓存是共享同一主存(MainMemory),在速度不一致、多线程操作同一个数据、多核的情况下将会导致每块内存数据可能不一致,此时又以哪个为准?为了解决这个问题,引入了缓存协议(MESI),总线锁等方法。
在操作系统中主要通过缓存一致性协议+总线锁实现数据的交互和同步。
- MESI只是缓存协议的一种,Inter的芯片用就是该协议实现缓存锁。
- M: 被修改(Modified)
- E: 独享的(Exclusive)
- S: 共享的(Shared)
- I: 无效的(Invalid)
-
总线锁:这个就是大杀器了,总线锁是把CPU和内存的通信给锁住,在这段时间,其他CPU不能操作其他内存(非常影响效率)。
现代CPU的数据一致性实现 = 总线锁 + 缓存锁(MESI)
指令重排序问题
为了使得CPU的计算能力很强,不可能让内外空间读写影响它的工作效率,所以CPU会对指令重排序,让CPU的工作效率提高。而当指令重排序后,CPU只保证该结果与顺序执行的结果一致,但是并不保证代码的执行顺序一致,例如在允许的时候将因此,如果一个计算过程依赖另一个计算过程的中间结果,而两者又是同一时间执行,那么就会因指令重排序而导致实际结果与预期结果不一致。
JVM规定重排序必须遵守的规则
- happens before:
- 程序顺序规则: 在一个单独的线程中,按照程序代码的执行流顺序,(时间上)先执行的操作happen—before(时间上)后执行的操作 (同一个线程中前面的所有写操作对后面的操作可见)
- 监视器锁规则(管理锁定规则):一个unlock操作happen—before后面(时间上的先后顺序)对同一个锁的lock操作。 (如果线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程))
- volatile变量规则:对一个volatile变量的写操作先行发生于后面(时间上)对这个变量的读操作。
- 线程启动规则:Thread.start()方法happen—before调用用start的线程前的每一个操作。(假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。注意:线程B启动之后,线程A在对变量修改线程B未必可见。)
- 线程终止规则:线程的所有操作都happen—before对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。 (线程t1写入的所有变量,在任意其它线程t2调用t1.join(),或者t1.isAlive() 成功返回后,都对t2可见。)
- 传递性:如果操作A先行于操作B,操作B先行于操作C,那么操作A先行于操作C。
- as-if-serial
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变
上面说的是指令重排序必学遵守的规则,在实现上又是什么原理? Java语言跑在JVM虚拟机上,那么肯定有JVM的实现方式。JVM解决指令重排序的是依靠读写屏障实现,分别为:LoadLoad屏障,StoreStore屏障,LoadStore屏障,StoreLoad屏障,我们举例volatile来说明。volatile保证的是数据的可见性和有序性。
-
可见性:是指被volatile修饰的共享变量一旦被更改,其他线程能够立即知道这个共享变量已经被修改了,当其他线程要读取这个变量的时候,最终会去内存中读取,而不是从自己的工作空间中读取。
-
有序性:是指被volatile修饰的共享变量不会被指令重排序。在写代码看来,我们的一个写操作例如 int i = 1是完整的,而且在Java语法上是不可被拆分的语句,但是我们看被编译成的class文件,它是分了两个阶段。
iconst_1 istore_1iconst_1 是将 int 型的 1 推送至栈顶。istore_1 把栈顶的元素弹出,并赋值给局部变量表中位置为“1”的变量,此时指变量i,这两句就相当于 int i = 1。所以我们经常以为一行代码是原子性的是不对的,或者认为不会受多线程影响其实是不全面的。为了保证被volatile修饰的变量不被重排序,在class文件上会添加ACC_VOLATILE标记符,在JVM层面实现volatile的有序性就是刚我们讲的读写屏障。
在汇编层面上用的lock指令实现,在Inter芯片层面上用的是MESI协议。
说完volatile,在Java中还有一个关键词就是synchronized也是常用到的,它保证了原子性、有序性和可见性,在class文件上同步方法块上加上monitorenter和monitorexit指令,在方法上是加ACC_SYNCHRONIZED标记符。在JVM层面是C和C++语言调用了操作系统的同步机制,汇编层面上是lock cmpxchg,Inter芯片层面上用的是MESI协议。
一个对象的诞生与消亡
对象的创建过程
// 还不了解class文件加载过程小伙伴可看我写的这篇文章:https://juejin.cn/post/6979869357266436103)
// 如果class文件未曾被加载
1. class loading
2. class linking(verfication,preparation,resolution)
3. class initializing
// 如果class文件已经被加载
1.申请对象内存
2.为成员赋默认值
3.调用构造方法<init>
A. 成员变量顺序赋初始值
B. 执行构造方法语句
对象的大小
例如:
public class Student {
private int age;
}
在64位的操作系统上,除开一些对象的其他必学元数据外,只计算对象在持有的真实数据,Student的对象总共占24字节(不开启压缩):mark word占用8字节,类指针8字节,age字段属于int占用4字节,由于整个对象的占用需要是8的倍数,所以padding占4个字节;如果开启-XX:+UseCompressedClassPointers和-XX:+UseCompressedOops则mark word占4字节,类指针4字节,age占4字节,padding占4字节,总共12字节。
对象的空间分配
对象new出来首先放在栈上(如果对象很小的话),栈上放不下放在线程本地存储(Thread Local Storage,简称TLS),线程本地存储放不下就看对象是不是太大了,如果是很大就放在老年代,如果不是很大就放在eden
对象的定位(对象的引用)
JVM虚拟机可通过两种方式定位对象,一是:句柄,二是:直接指针;HotSpot是使用直接指针。
-
句柄池:如果使用句柄访问方式,Java堆中会划分出一块内存作为句柄池,JVM栈中的局部变量表中的reference存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。使用句柄方式最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
-
直接指针:如果使用该方式,Java堆对象的布局就必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象地址。使用直接指针方式最大的好处就是速度更快,它节省了一次指针定位的时间开销。
对象的引用存在哪里?
对象的引用存在栈中的局部变量表中(局部变量表的介绍中有)
碎片记录
JAVA的三种常量池
- class文件常量池(静态常量池)
在JVM之我们的代码是如何被加载到JVM执行的?我们有阅读过class文件,在class文件中的constant pool即为静态常量池。静态常量池主要包含字面量和符号引用。
静态常量池是存在编译期间的,描述了这个类的各种信息。
- 运行时常量池
运行时常量池,则是jvm虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。每个类都有一个运行时常量池并非一成不变,在运行时也可以将新内容放进去,这种特性常被用来框架开发。
在class经历过Loading,Linking,Initializing之后,class对象被加载进内存,类的方法代码,变量名,方法名等等都是在方法区(符号引用转为直接引用),运行时常量池也跟着在方法区,但是class对象是在堆里的。
- 字符串常量池
字符串常量池也是我们经常接触的,在类完成加载的过程中,也会生成字符串对象实例,然后将该对象的引用存入到字符串常量池中(注意:字符串常量池中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的(JDK 1.7以后))。
在网上关于字符串常量池到底是在哪里的讨论真是让人头秃,各种说法不同,但是我们知道的JDK 1.7之前,方法区由永久代实现,但是由于永久代GC无法清除,所以当创建大量的字符串时会出现OOM,所以后面1.7后将方法区改为元空间+堆实现,而且1.7后我们不断创建字符串能够看见堆区的内存一直升高,也佐证了说法。
详细的证明论述文章:Java 字符串常量池到底是在PermGen方法区、是在heap堆里面、还是在Metaspace 元空间里面呢?
至此,我们简单的了解了JVM的运行时数据区,也明白了各个数据区是用来干什么的,也了解为什么我们在多线程中为什么出现数据不安全的问题的由来,以及在JVM层面,汇编层面是提供了什么机制让我们的数据在多线程时能够得到安全保证。也顺便了解了常说的三个常量池。在了解了这么多与数据存储有关的概念,也为下一篇关于GC相关的知识做铺垫。
谢谢小伙伴花时间阅读这篇文章,祝愿小伙伴在阅读这篇文章有所收获,如果在阅读文章时发现有问题或者有什么疑惑欢迎一起来讨论,一起进步。