JAVA从内存模型到GC
内存划分
根据 JVM 规范,JVM 内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。
虚拟机栈
每个线程有一个私有的栈,随着线程的创建而创建。存放了局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息。
StackOverflowError
栈的大小可以固定也可以动态扩展。当栈调用深度大于JVM所允许的范围,会抛出StackOverflowError的错误,不过这个深度范围不是一个恒定的值,我们通过下面这段程序可以测试一下这个结果:
栈溢出测试源码:
package com.paddx.test.memory;
public class StackErrorMock {
private static int index = 1;
public void call(){
index++;
call();
}
public static void main(String[] args) {
StackErrorMock mock = new StackErrorMock();
try {
mock.call();
}catch (Throwable e){
System.out.println("Stack deep : "+index);
e.printStackTrace();
}
}
}
运行三次,可以看出每次栈的深度都是不一样的,输出结果如下。
虚拟机栈除了上述错误外,还有另一种错误,那就是当申请不到空间时,会抛出 OutOfMemoryError。这里有一个小细节需要注意,catch 捕获的是 Throwable,而不是 Exception。
因为 StackOverflowError 和 OutOfMemoryError 都不属于 Exception 的子类。
本地方法栈
与虚拟机栈相似,这部分主要服务于 Native 方法,一般情况下, Java 应用程序员并不需要关心这部分的内容。线程私有。
程序计数器(PC 寄存器)
程序计数器,也叫PC 寄存器。JVM支持多个线程同时运行,每个线程都有自己的程序计数器。倘若当前执行的是 JVM 的方法,则该寄存器中保存当前执行指令的地址;倘若执行的是native 方法,则PC寄存器中为空。线程私有。
堆
堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和数组都在堆上进行分配。这部分空间可通过 GC 进行回收。线程共享。
Java中对象是采用new或者反射的方法创建的,这些对象的创建都是在堆(Heap)中分配的,所有对象的回收都是由Java虚拟机通过垃圾回收机制完成的。GC为了能够正确释放对象,会监控每个对象的运行状况,对他们的申请、引用、被引用、赋值等状况进行监控。
OutOfMemoryError
当申请不到空间时会抛出,下面我们简单的模拟一个堆内存溢出的情况:
package com.paddx.test.memory;
import java.util.ArrayList;
import java.util.List;
public class HeapOomMock {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<byte[]>();
int i = 0;
boolean flag = true;
while (flag){
try {
i++;
list.add(new byte[1024 * 1024]);//每次增加一个1M大小的数组对象
}catch (Throwable e){
e.printStackTrace();
flag = false;
System.out.println("count="+i);//记录运行的次数
}
}
}
}
运行上述代码,输出结果如下:
总结:Java内存堆和栈区别
栈内存用来存储基本类型的变量和对象的引用变量;堆内存用来存储Java中的对象,无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中
栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。堆内存中的对象对所有线程可见,堆内存中的对象可以被所有线程访问
如果栈内存没有可用的空间存储方法调用和局部变量,JVM会抛出java.lang.StackOverFlowError,如果是堆内存没有可用的空间存储生成的对象,JVM会抛出java.lang.OutOfMemoryError。
方法区
方法区也是所有线程共享。主要用于存储类的信息、常量池、方法数据、方法代码等。方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。 线程共享。
PermGen(永久代)
绝大部分 Java 程序员应该都见过 "java.lang.OutOfMemoryError: PermGen space "这个异常。这里的 “PermGen space”其实指的就是方法区。不过方法区和“PermGen space”又有着本质的区别。前者是 JVM 的规范,而后者则是 JVM 规范的一种实现,并且只有 HotSpot 才有 “PermGen space”。
出现的场景:频繁加载类。
- JSP。
- SmartKit执行Python。
- 其他动态加载类的场景。
老年代溢出示例
package com.paddx.test.memory;
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.List;
public class PermGenOomMock{
public static void main(String[] args) {
URL url = null;
List<ClassLoader> classLoaderList = new ArrayList<ClassLoader>();
try {
url = new File("/tmp").toURI().toURL();
URL[] urls = {url};
while (true){
ClassLoader loader = new URLClassLoader(urls);
classLoaderList.add(loader);
loader.loadClass("com.paddx.test.memory.Test");
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
运行结果如下:
本例中使用的 JDK 版本是 1.7,指定的 PermGen 区的大小为 8M。通过每次生成不同URLClassLoader对象来加载Test类,从而生成不同的类对象,这样就能看到我们熟悉的 "java.lang.OutOfMemoryError: PermGen space " 异常了。
Metaspace(元空间)
和永久带的差别
- 存储空间不再位于元数据空间不再位于虚拟机,而是本地内存,所以内存大小收本地内存限制。
- 符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。
相同点:
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。
以字符串常量为例,来比较 JDK 1.6 与 JDK 1.7及 JDK 1.8 的区别:
package com.paddx.test.memory;
import java.util.ArrayList;
import java.util.List;
public class StringOomMock {
static String base = "string";
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
for (int i=0;i< Integer.MAX_VALUE;i++){
String str = base + base;
base = str;
list.add(str.intern());
}
}
}
这段程序以2的指数级不断的生成新的字符串,这样可以比较快速的消耗内存。我们通过 JDK 1.6、JDK 1.7 和 JDK 1.8 分别运行:
JDK 1.6 的运行结果:
JDK 1.7的运行结果:
JDK 1.8的运行结果:
方法区内存设置
-XX:MetaspaceSize,初始空间大小。
-XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
内存大小设置
控制参数
参考资料
https://www.oracle.com/technetwork/articles/java/vmoptions-jsp-140102.html
JAVA内存模型
Java内存模型即Java Memory Model,简称JMM。JVM内存的划分,定义了内存的结构,JMM定义了Java 虚拟机在JVM内存中的工作方式。
内存模型介绍
主要内存
主内存:堆,存放共享数据。
工作内存:虚拟机栈。存放线程私有基础类型变量以及对象的引用。
方法区:共享类以及方法等。
线程数据交换
数据传输流程都需要先从线程私有内存写入共享内存之后再同步到其他线程。
例如,线程A设置X=1,线程B,进行读取。
三个特性
原子性
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量int x,两条线程同时对他赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。
可见性
理解了指令重排现象后,可见性容易了,可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。
有序性
有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的
happens-before
- 程序顺序原则/语义一致性原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
- 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
- volatile规则: volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
- 线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
- 传递性: A先于B ,B先于C 那么A必然先于C
- 线程终止规则 :线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
- 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
- 对象终结规则 对象的构造函数执行,结束先于finalize()方法
volatile和synchronized
特点
首先需要理解线程安全的两个方面:执行控制和内存可见。
执行控制:
控制代码执行(顺序)及是否可以并发执行。
内存可见:
控制的是线程执行结果在内存中对其它线程的可见性。
根据Java内存模型的实现,线程在具体执行时,会先拷贝主存数据到线程本地(CPU缓存),操作完成后再把结果从线程本地刷到主存。
synchronized 控制的是执行顺序。
volatile 控制的是,内存的可见性。
参考资料
https://blog.csdn.net/javazejian/article/details/72772461
垃圾回收
常见垃圾回收器
垃圾回收算法
有向图和根节点
垃圾回收用到的比较基础的算法就是通过有向图的方式找到根节点到对象是否存在引用,从而判定是否需要回收。
例如:我们创建了三个对象a、b、c:
如果某一个对象 x引用了另一个对象 y,那我们就通过一条有向边把这两个对象的引用关系表示出来,那么上述程序就可以表示成:
具体算法可参见:https://www.cnblogs.com/xuqiang/archive/2011/03/28/1997680.html
回收算法
- 标记-清除算法 Mark-Sweep GC
• 标记阶段:从根集合出发,将所有活动对象及其子对象打上标记
• 清除阶段:遍历堆,将非活动对象(未打上标记)的连接到空闲链表上
优点
实现简单, 容易和其他算法组合
缺点
碎片化, 会导致无数小分块散落在堆的各处
分配速度不理想,每次分配都需要遍历空闲列表找到足够大的分块
与写时复制技术不兼容,因为每次都会在活动对象上打上标记
标记和清除两个过程效率不高,产生内存碎片导致需要分配较大对象时无法找到足够的连续内存而需要触发一次GC操作
在这里插入图片描述
在Java语言里,可作为GC Roots的对象包括以下几种:
虚拟机栈(栈帧中的本地变量表)中的引用的对象
方法区中的类静态属性引用的对象
方法区中的常量引用的对象。
本地方法栈中JNI(即一般说的Native方法)的引用的对象。
标记-压缩/整理 Mark-Compact
和“标记-清除”相似,不过在标记阶段后它将所有活动对象紧密的排在堆的一侧(压缩),消除了内存碎片, 不过压缩是需要花费计算成本的。
优点
有效利用了堆,不会出现内存碎片 也不会像复制算法那样只能利用堆的一部分
缺点
压缩过程的开销,需要多次搜索堆
引用计数 Reference Counting
引用计数,就是记录每个对象被引用的次数,每次新建对象、赋值引用和删除引用的同时更新计数器,如果计数器值为0则直接回收内存。 很明显,引用计数最大的优势是暂停时间短
优点
可即刻回收垃圾
最大暂停时间短
没有必要沿指针查找, 不要和标记-清除算法一样沿着根集合开始查找
缺点
计数器的增减处理繁重
计数器需要占用很多位
实现繁琐复杂, 每个赋值操作都得替换成引用更新操作
循环引用无法回收。
复制算法
按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉
缺点:将内存缩小为了原来的一半
收集策略-分代回收
出发点:大部分对象生成后马上就变成垃圾,很少有对象能活的很久
Eden:from:to 8:1:1
分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效率。新生代基本采用复制算法,老年代采用标记整理算法。
年轻代(Young Generation)
1.所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
2.新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。
3.当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收
4.对下再多次回收后还存在,将放到老年代内。
5.新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)
年老代(Old Generation)
1.因此,可以认为年老代中存放的都是一些生命周期较长的对象。
2.内存比新生代和老年代大概比例是1:2,当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。
方法区
用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。
MinorGC&FullGC
Minor GC通常发生在新生代的Eden区,在这个区的对象生存期短,往往发生GC的频率较高,回收速度比较快,一般采用复制-回收算法
Full GC/Major GC 发生在老年代,一般情况下,触发老年代GC的时候不会触发Minor GC,所采用的是标记-清除算法
内存分配与回收策略
结构(堆大小 = 新生代 + 老年代 ):
新生代(1/3)(初始对象,生命周期短):Eden 区、survivior 0、survivior 1( 8 : 1 : 1)
老年代(2/3)(长时间存在的对象)
一般小型的对象都会在 Eden 区上分配,如果Eden区无法分配,那么尝试把活着的对象放到survivor0中去(Minor GC)
如果survivor0可以放入,那么放入之后清除Eden区
如果survivor0不可以放入,那么尝试把Eden和survivor0的存活对象放到survivor1中
如果survivor1可以放入,那么放入survivor1之后清除Eden和survivor0,之后再把survivor1中的对象复制到survivor0中,保持survivor1一直为空。
如果survivor1不可以放入,那么直接把它们放入到老年代中,并清除Eden和survivor0,这个过程也称为分配担保(Full GC),大对象、长期存活的对象则直接进入老年代。
各垃圾回收器介绍
Serial收集器
串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。新生代、老年代使用串行回收;新生代复制算法、老年代标记-压缩;垃圾收集的过程中会Stop The World(服务暂停)
参数控制:-XX:+UseSerialGC 串行收集器
ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本。新生代并行,老年代串行;新生代复制算法、老年代标记-压缩。
参数控制:-XX:+UseParNewGC ParNew收集器
-XX:ParallelGCThreads 限制线程数量
Parallel Scavenge收集器
Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例;新生代复制算法、老年代标记-压缩
参数控制:-XX:+UseParallelGC 使用Parallel收集器+ 老年代串行
Serial Old收集器
老年代的收集器,与Serial一样是单线程,不同的是算法用的是标记-整理(Mark-Compact)。
因为老年代里面对象的存活率高,如果依旧是用复制算法,需要复制的内容较多,性能较差。并且在极端情况下,当存活为100%时,没有办法用复制算法。所以需要用Mark-Compact,以有效地避免这些问题。
-XX:+UseParallelOldGC
Parallel Old 收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供
参数控制: -XX:+UseParallelOldGC 使用Parallel收集器+ 老年代并行
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。
从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:
初始标记(CMS initial mark)
并发标记(CMS concurrent mark)
重新标记(CMS remark)
并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)
优点:并发收集、低停顿
缺点:产生大量空间碎片、并发阶段会降低吞吐量
参数控制:-XX:+UseConcMarkSweepGC 使用CMS收集器
-XX:+ UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长
-XX:+CMSFullGCsBeforeCompaction 设置进行几次Full GC后,进行一次碎片整理
-XX:ParallelCMSThreads 设定CMS的线程数量(一般情况约等于可用CPU数量)
G1收集器
G1收集器和CMS收集起比较类似,相比CMS收集器有以下特点:
- 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。
- 可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
详细信息可参考:https://blog.csdn.net/j3T9Z7H/article/details/80074460
ZGC收集器
JAVA 11的收集器,真正做到所有阶段都并发执行。超低停顿。
具体可参见:https://juejin.im/entry/5b86a276f265da435c4402d4。
总结
GC垃圾收集器
Serial New收集器是针对新生代的收集器,采用的是复制算法
Parallel New(并行)收集器,新生代采用复制算法,老年代采用标记整理
Parallel Scavenge(并行)收集器,针对新生代,采用复制收集算法
Serial Old(串行)收集器,新生代采用复制,老年代采用标记清理
Parallel Old(并行)收集器,针对老年代,标记整理
CMS收集器,基于标记清理
G1收集器(JDK):整体上是基于标记清理,局部采用复制
综上:新生代基本采用复制算法,老年代采用标记整理算法。cms采用标记清理
finalize
当对象将要被垃圾回收的时候会执行此方法。一般不建议使用 ,因为:
(1).对象不一定会被回收。
(2).垃圾回收不是析构函数。
(3).垃圾回收只与内存有关。
(4).垃圾回收和finalize()都是靠不住的,只要JVM还没有快到耗尽内存的地步,它是不会浪费时间进行垃圾回收的。
(5).可能发生finalize逃逸,导致对象不能被回收。
参考资料
https://crowhawk.github.io/2017/08/15/jvm_3/
GC与Java四类引用
强引用(StrongReference)
强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题
软引用(SoftReference)
如果内存空间不足,就会回收这些对象的内存。软引用适合用来做内存敏感的高速缓存。
弱引用(WeakReference)
弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收不活跃的弱引用的内存。
虚引用(PhantomReference)
虚引用在任何时候都可能被垃圾回收器回收,主要用来跟踪对象被垃圾回收器回收的活动,被回收时会收到一个系统通知。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。