JMM、JVM
1、java内存模型
Java内存模型简称JMM(Java Memory Model),定义了线程和内存之间的抽象关系,即JMM定义了JVM在计算机内存(RAM)的工作方式,它描述了一组规则或规范,通过这组规范定义了程序中的变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。其实和内存区域不一样的东西。内存区域是指JVM运行时将数据分区域存储,强调对内存空间的划分,即运行时数据区(Runtime Data Area)。
1.1. JMM内存模型
JMM通过一组规则控制各个变量在共享数据区域和私有区域的访问方式,是围绕原子性,有序性和可见性。
- 主内存:主要存储java的实例对象,所有线程创建的对象都放在主内存(包括成员变量和方法中的本地变量),也包括了共享的类信息,常量和静态变量。
- 工作内存:主要存储当前方法的所有本地变量(工作内存中存储的是主内存中的共享变量的副本拷贝),每个线程只能访问了自己的工作内存,线程中的变量对其他线程不可见。
JMM存在的必要性
由于JVM运行程序的实体是线程,而每个线程的创建JVM都会为其创建一个工作内存,用于线程存储私有数据,线程与主内存中的变量操作必须通过工作内存间接完成。其主要过程是将将主内存中的变量拷贝到各个线程的工作内存,然后对变量进行操作,操作完成后,将变量重新写回主内存。如果此时有两个线程同时对主内存中的同一个变量进行操作,就可能诱发线程安全问题。 假设存在一个共享X=1,两个线程1和2分别对X进行操作,A/B各自拷贝变量X的副本到自己的工作内存。假设A线程先修改X的值为2,而B线程需要读取X的值,那么B线程读取的X值是更新钱的1还是更新后的2呢? 答案:不确定。因为工作内存是线程的私有数据区域,当A操作X结束后,将X写回主内存,此时B线程读取主内存中的X,则B线程读取的变量副本X为2;若线程A操作X时,未将操作后的X写回主内存,此时B线程读取X,则B线程读取的变量副本X为1。
一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存,JMM定义了以下八种操作: 数据同步八种原子操作:
- lock(锁定):作用于主内存的变量,把一个变量标记为一个线程独享状态
- unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量可以被其他线程锁定
- read(读取):作用于主内存的变量,把一个变量从主内存中传输到线程工作内存,以便于线程后续的load工作使用
- load(载入):作用于工作内存的变量,把read操作从主内存读取到的变量放入工作内存中的变量
- use(使用):作用于工作内存的变量,把工作内存中的一个变量传递给执行引擎
- assign(赋值):作用于工作内存的变量,把执行引擎接收到的值赋值给工作内存的变量
- store(存储):作用于工作内存的变量,把工作内存的一个变量传送到主内存的变量,以便于后续的write操作
- write(写入):作用于主内存的变量,把store操作中传输工作内存的变量值赋值到主内存的变量
如果需要把主内存的一个变量复制到工作内存中,需要按顺序的执行read和load操作;如果需要把工作内存的变量同步到主内存中,需要按顺序的执行store和write操作
1.2. 指令重排序和happens-before
- 指令重排序 在执行程序时为了提高性能,编译器和处理器通常会对指令进行重排序。重排序分三种类型: 1.编译器优化的重排序:编译器在不改变单线程程序语义的情况下,可以安排语句的执行顺序 2.指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Paralleism)来将指令重叠执行。如果不存在数据依赖,处理器可以改变语句对应的机器指令的顺序。 3.内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
- as-if-serial as-if-serial语义:不管怎么充排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器,runtime和处理器都必须遵守as-if-serial语义。为了遵循as-if-serial,编译器和处理器不会对存在数据依赖关系的操作进行重排序,因为这种重排序会改变操作结果。
- happens-before
只靠volatile和synchronized来保证原子性,可见性及有序性,并发变成会变得比较麻烦。Java提供了happens-before来辅助保证程序的原子性,可见性和有序性,保证两个操作的顺序性。两个操作可以在一个线程中,也可以不在一个线程中,JMM通过happens-before关系向程序员提供跨线程的内存可见性保证(如线程A的写操作和线程B的读操作存在happens-before,虽然在两个线程中,但JMM向程序员保证A的操作对B可见)。
happens-before规则:
- 程序顺序规则:一个线程中的操作,happens-before该线程中的任何后续操作
- 监视器锁规则:对于一个监视器的解锁,happens-before该监视器后续的加锁
- 传递性:甲happens-before乙,乙happens-before丙,则甲happens-before乙
- volatile规则:一个volatile修饰的变量的写,happens-before后续对该volatile变量的读
1.3. 内存屏障和volatile关键字
volatile是java虚拟机提供的轻量级同步机制,可以保证可见性,但无法保证原子性(如:i++),
- 保证可见性:当一个线程修改了一个volatile修饰的变量的值,这个变量修改后的值总对其他线程立即得知。即时可见通过缓存一致性保证
- 禁止指令重排:通过内存屏障
为了实现volatile的内存语义,编译器生成字节码时,会在指令序列中插入内存屏障禁止特定类型的处理器重排序。JMM采取以下的保守内存屏障策略:
1.在每个volatile的写操作前插入一个StoreStore屏障
2.在每个volatile的写操作后插入一个StoreLoad屏障
3.在每个volatile的读操作前插入一个LoadLoad屏障
4.在每个volatile的读操作后插入一个LoadStore屏障
内存屏障: 又称为:内存栅栏,是一个CPU指令,作用有两个,一是保证特定操作的顺序性,二是保证某些变量的内存可见性。编译器和处理器都会对执行进行重排优化,intel硬件提供了一系列的内存屏障,主要有Ifence(读屏障)、sfence(写屏障)、menece(全能屏障)、Lock前缀等。 JMM屏蔽了这些硬件平台的差异,由JVM为不同的机器平台生成相应的机器码。java内存屏障主要有Load和Store两种,对于Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存读取数据;对于Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写入主内存。
JVM中提供了四种内存屏障 |指令|说明|
|:---|:---|
|Load1;LoadLoad;Load2|要确保Load1所要读入的数据能够在Load2及其后续的Load指令访问前读入|
|Store1;StoreStore;Store2|要确保Store1的数据在Store2及后续的Store指令前对其他处理器可见(将Store1的数据刷入主内存)|
|Load1;LoadStore;Store2|要确保Load1的数据在Store2及后续的Store指令被刷新前读取|
|Store1;StoreLoad;Load2|要确保Store1的在据在Load2及后续的Load操作前对其他处理器可见|
2、JVM内存结构
JVM(java virtural machine)java虚拟机,虚拟出来的计算机。Java语言使用JVM屏蔽了与具体平台相关的信息,使得Java语言编译后只需要生成在JVM上运行的目标代码即可,就可以在多个平台运行。
2.1. 类加载
类加载过程可以分为:加载,链接、初始化三个过程
1.类加载: JVM通过类的全限定类名查找Class字节码文件并将其加载到内存的过程,需要借助类加载器来加载。
- Bootstrap ClassLoader(启动类加载器):负责加载$JAVA_HOME中jre/lib/rt.jar中所有的class或者-Xbootstrapclasspath选项指定的jar包,由C++实现,不是ClassLoader的子类
- Extension ClassLoader(扩展类加载器):负责java平台扩展功能的一些jar包,包括$JAVA_HOME中jre/ext/*.jar或者-Djava.ext.dir指定目录下的jar包
- App ClassLoader(系统类加载器):负责加载classpath指定的jar包或者-Djava.class.path多指定的类或jar
- Custom ClassLoader(自定义类加载器):通过java.class.ClassLoader的子类自定义加载class,属于应用程序根据需要自定义的ClassLoader
2.链接: 链接是将已经构造好的类合并到JVM,需要进行验证这个类的结构是否符合java规范
- 验证:验证加载的类是否符合JVM规范(文件格式验证,元数据验证,字节码验证,符号引用验证)
- 准备:给类的静态字段分配内存
- 解析:将符号引用转为直接引用
3.初始化: 给类的静态字段赋值,其中包括静态字段的赋值语句和 static 代码块中的语句。这两部分的代码会被合并到函数中,java 虚拟机会保证该方法只被执行一次
双亲委派机制:1.自下而上检查类是否已加载,2.自上而下加载类
加载过程:
- 当app尝试加载一个类时,它不会直接尝试加载这个类,首先会在自己命名空间中查询是否已加载过这个类,如果没有加载,会把这个类的加载请求委派给父类加载器Ext完成
- 当Ext尝试加载一个类时,它也不会直接尝试加载这个类,首先会在自己命名空间查询是否已经加载过这个类,如果没有加载,会把这个类的加载请求委派给父级加载器Bootstrap完成
- 如果Bootstrap加载失败,代表这个类不在Bootstrap的加载范围,那么Bootstrap会将这个类的加载请求重新交给子类Ext加载器加载
- 如果Ext加载失败,代表这个类也不在Ext的加载范围,最后会重新将这个类的加载请求交给子类加载器App完成
- 如果App加载失败,代表这个类根据全限定名无法查找到,则会ClassNotFoundException异常
2.2. JVM内存结构
JVM内存模型实际是java运行时数据区域,它整个过程是当程序要执行某一段代码时,类加载器加载class字节码文件,把读取的信息翻译成类信息存放到方法区,
同时在堆中生成该类的Class对象,当程序运行调用方法时,局部变量、对象引用、数组引用会在虚拟机栈中生成,
如果在方法调用中需要new对象,则会在堆中根据Class信息生成对象,最后由字节码执行引擎解释执行方法区的代码块,就完成了代码执行。
当然这个过程中,涉及到线程的切换,这时程序计数器就派上用场了,它会记录当前线程执行到哪一行代码,下次该线程再次获取cpu执行权时,继续从该行代码继续执行,
最后本地方法栈和虚拟机栈的功能几乎一模一样,只不过它是执行的native本地方法,底层是调用的C或C++代码。
根据Java虚拟机规范,java虚拟机将管理的内存分为5大区域:
1.PC(程序计数器)
- 一块较小的内存,存储当前线程执行的字节码行号指示器
- 线程私有,每个线程都有一个独立的PC,随着线程的创建而创建,随着线程的销毁而销毁
- 唯一一个不会发生OOM的区域
2.私有栈
- 线程私有
- 虚拟机栈是一个一个栈帧,栈帧包含:局部变量表,操作数栈,动态链接,方法的返回地址信息等
- 每个方法执行的时候会创建一个栈帧,从方法的调用到结束
- 局部变量表:局部变量表主要存放方法参数及方法内部定义的局部变量
- 操作数栈:用于保存方法中计算过程的中间结果,同时作为计算过程中变量临时的存储空间
- 动态链接:每个栈帧都保存了一个可以执行当前线程所在类的运行时常量池
- 方法返回:存放该方法在寄存器中的值,即该方法的指令地址。
- 若线程申请的栈帧深度大于JVM允许的最大深度,则SOF;JVM动态扩展时,无法申请足够的内存,则OOM
3.本地方法栈
- 线程私有
- 主要用来管理native方法
- 占用内存不固定,需动态扩展
4.方法区
- 线程共享区域
- 方法区主要用来存储已被加载的类信息、静态变量、常量、即时编译的代码数据
- 无法获取内存时OOM
5.堆
- 线程共享区域
- JDK1.7为永久代,1.8后为元空间,使用直接内存
- JVM GC的主要区域,主要分为年轻代、老年代
3、GC
任何语言在运行过程中都会创建对象,也就意味这需要在内存中为这些对象分配内存空间,当对象失去使用的意义时,需要回收这部分内存区域,保证有足够的空间分配给其他的对象。这种对象内存的释放就是垃圾回收机制,简称GC。 JVM在进行垃圾回收时,会针对三个区域分别进行GC,分别是年轻代,老年代,方法区,大部分是在年轻代。GC的类型主要有以下三种:
- Minor GC(新生代收集):针对年轻代的垃圾收集,在Eden区满时触发Minor GC,Survivor满时不会触发Minor GC。
- Major GC(老年代收集):针对老年代的垃圾收集,目前只有CMS收集器会单独收集老年代。
- Full GC(整堆收集):收集整个java堆和方法区。
3.1. Minor GC
Minor GC是指新生代GC,即发生在新生代(包括Eden和Survivor)的垃圾回收操作,当新生代无法为新对象分配内存空间时,会触发Minor GC。因为新生代的大多数对象生命周期很短,所以发生Minor GC的频率比较高。 Minor GC触发: 当大量线程不断制造对象,由于创建的对象优先分配在新生代的Eden区中,当Eden区满时,则触发Minor GC,此时会将Eden和From Survivor中的对象复制到To Survivor中,当经历若干次(默认15次)Minor GC后,对象依然存活,则直接进入老年代。
3.2. Major GC
Major GC是指老年代GC,即发生在老年代(Tenured)的垃圾回收操作,当老年代空间不足时,会尝试触发一次Minor GC;如果空间依然不足,会触发Major GC;如果Major GC之后空间施依然不足,则OOM(Out of Memory内存溢出)。
3.3. Full GC
Full GC是指整堆收集,即发生在java堆和方法区的垃圾回收操作。 Full GC触发:
- 调用System.gc()时,系统建议执行Full GC,但不保证必然执行
- 老年代空间不足:Survivor区中的对象满足晋升为老年代时,晋升的对象大于老年代的可用内存,则触发Full GC;老年代没有足够大的连续空间存放大对象(如:大对象200k,老年代连续空间100k),则会触发Full GC
- 方法区空间不足
- Mionr GC时,Survivor区中的相同年龄对象占比大于一半时,将这部分大于这个年龄的对象移动到老年代,老年代的可用空间小于这部分对象,则触发Full GC。
4、垃圾收集算法
JVM在对堆进行垃圾回收时,首先要确定这些对象中哪些对象还是"存活",哪些还是"死去"(不再被使用的对象)
4.1. 如何判断一个对象是否存活
- 引用计数法
引用计数法是给对象添加一个引用计数器,用一块额外的区域存储每个对象被引用的次数,当对象被一个地方引用时,则对象的引用数加1;反之,当对象的一个一个引用失效,则对象的引用数减1。当一个对象的引用数为0时,则认为该对象不再被使用了,可以被回收
- 可达性分析法
可达性分析法通过以所有的“GC Roots”对象为出发点,如果无法通过GC Roots的引用追踪到的兑现,则认为对象不再被引用,可以被回收
GC Roots需要满足一个条件,就是长时间不会被回收掉:
- 虚拟机栈中本地变量引用的对象
- 方法区中静态属性引用的对象
- 方法区常量引用的对象
- 本地方法引用的对象
- 垃圾收集器引用的对象
- 所有被同步锁持有的对象
4.2. 垃圾回收算法
1.标记清除法
- 标记:遍历内存区域,把需要回收的对象打上标记
- 清除:清除掉被标记的对象,释放内存空间
缺点:
- 标记-清除耗时较长
- 空间产生大量碎片,导致后续需要分配较大对象时,无法找到足够的连续内存空间而导致另一次垃圾回收
2.标记复制
将内存分为两块,一次只使用其中的一块,当一块使用完,将还存活的对象复制到另一块,然后将使用过的内存清除掉。
缺点:
- 空间效率利用率低
- 如果多数是存活的对象,复制开销比较大
3.标记整理
将内存中存活的对象进行标记,让存活的对象移动到内存的一端,然后回收内存
优点:解决了标记清除算法空间碎片的问题,也解决了标记复制浪费空间的问题
缺点:性能低
5、垃圾收集器
常见的垃圾收集器有串行收集器(Serial)、并行收集器(Parallel)、并发收集器(CMS)、G1收集器
5.1. 串行收集器
单线程中只使用一个线程进行垃圾回收,会暂停所有用户线程
- 堆内存中新生代垃圾收集器(Serial):使用一个线程去回收,在垃圾收集过程中会产生较长时间的停顿(STW:stop the world),使用复制算法
- 堆内存中老年代垃圾收集器(Serial old):Serial old是Serial垃圾收集器老年代版本,它同样是单线程的收集器,使用标记-整理算法
5.2. 并行收集器
多个垃圾收集器并行工作
- 堆内存中新生代垃圾收集器(ParNew/Parallel Scavenge):ParNew是Serial的并发版本,采用并发复制算法;Parallel Scavenge也是一个新生代垃圾收集器,使用复制算法,俗称吞吐量优先收集器
- 堆内存老年代垃圾收集器(Parallel old):Parallel old收集器是Parallel Scavenge的老年代版本,使用多线程的标记-整理算法
5.3. 并发收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿为目的的收集器,用户线程和垃圾收集线程同时执行(不一定是并行,可能交替执行),不需要停顿用户线程
- 初始标记:只是标记一下GC Roots能关联的对象,速度很快,会发生STW但很快
- 并发标记:并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的 对象状态发生改变
- 重新标记:暂停所有的其他线程(STW),重新标记阶段是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
- 并发清除:开启用户线程,同时GC线程开始对未标记的区域做清扫
- 并发重置:重置本次GC过程中的标记数据
5.4. G1收集器
G1的回收流程和CMS逻辑大致相同,分别进行初始标记、并发标记、重新标记、筛选清除,区别在最后一个阶段G1不会直接进行清除,而是会根据设置的停顿时间进行智能的筛选和局部的回收
G1不再分配连续的内存空间给年轻代,s1,s2,老年代,而是把内存分为一块一块的Region(默认2048 )。G1专门跟配大对象的Humongous,大对象定义默认大小超过Region的50%,一个大对象可以横跨多个Region存储。Full GC时除了年轻代和老年代,也会一同回收掉Humongous区。
- 初始标记:标记处GCRoots能直接关联到的对象
- 并发标记:从GCRoots对象遍历整个对象,耗时长,但与用户线程并发,不会STW
- 重新标记:修正并发期间产生的新对象记录,STW耗时比初始标记长,但不是很长
- 筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划(-XX:MaxGCPauseMillis=默认200ms)