堆参数调优

在进行堆参数调优前,我们可以通过下面的代码来获取虚拟机的相关内存信息。
package com.jane;

/**
* @author jane
* @create 2021-03-09 22:04
*/
public class JVMMemory
{
public static void main(String[] args)
{
// 返回 Java 虚拟机试图使用的最大内存量
long maxMemory = Runtime.getRuntime().maxMemory();
System.out.println("MAX_MEMORY = " + maxMemory + "(字节)、" + (maxMemory / (double) 1024 / 1024) + "MB");
// 返回 Java 虚拟机中的内存总量
long totalMemory = Runtime.getRuntime().totalMemory();
System.out.println("TOTAL_MEMORY = " + totalMemory + "(字节)、" + (totalMemory / (double) 1024 / 1024) + "MB");
}
}
运行结果
MAX_MEMORY = 1873805312(字节)、1787.0MB
TOTAL_MEMORY = 126877696(字节)、121.0MB

Process finished with exit code 0
这些值是算出来的?看下图就明白了,
虚拟机最大内存为物理内存的1/4
而初始分配的内存为物理内存的1/64

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_老年代

IDEA中如何配置JVM内存参数?
在【Run】->【Edit Configuration…】->【VM options】中,
输入参数-Xms10m -Xmx10m -XX:+PrintGCDetails,然后保存退出。

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_内存空间_02

JVM的初始内存和最大内存一般怎么配?
答:初始内存和最大内存一定是一样大,理由是避免GC和应用程序争抢内存,
进而导致内存忽高忽低产生停顿。

堆溢出 OutOfMemoryError

把堆内存调成10M后,再一直new对象,导致Full GC也无法处理,
直至撑爆堆内存,进而导致OOM堆溢出错误,程序及结果如下:
package com.jane;

import java.util.Random;

/**
* @author jane
* @create 2021-03-09 22:04
*/
public class JVMMemory
{
public static void main(String[] args)
{
// 返回 Java 虚拟机试图使用的最大内存量
long maxMemory = Runtime.getRuntime().maxMemory();
System.out.println("MAX_MEMORY = " + maxMemory + "(字节)、" + (maxMemory / (double) 1024 / 1024) + "MB");
// 返回 Java 虚拟机中的内存总量
long totalMemory = Runtime.getRuntime().totalMemory();
System.out.println("TOTAL_MEMORY = " + totalMemory + "(字节)、" + (totalMemory / (double) 1024 / 1024) + "MB");

int i = Runtime.getRuntime().availableProcessors();
System.out.println("核数:"+i);

String str = "jane";
while (true)
{
// 每执行下面语句,会在堆里创建新的对象
str += str + new Random().nextInt(88888888) + new Random().nextInt(999999999);
}
}
}

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_内存空间_03

如果出现java.lang.OutOfMemoryError: Java heap space异常,
说明Java虚拟机的堆内存不够,造成堆内存溢出。原因有两点:
1.Java虚拟机的堆内存设置太小,可以通过参数-Xms和-Xmx来调整。
2.代码中创建了大量对象,并且长时间不能被GC回收(存在被引用)。

GC(Java Garbage Collection)

GC垃圾收集机制

对于GC垃圾收集机制,我们需要记住以下几点:
次数上频繁收集Young区。
次数上较少收集Old区。
基本不动元空间。

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_java_04

JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,
大部分时候回收的都是指新生代。
因此GC按照回收的区域又分了两种类型,
一种是普通GC(minor GC),
一种是全局GC(major GC or Full GC)

Minor GC和Full GC的区别:
(1)普通GC(minor GC):只针对新生代区域的GC,
指发生在新生代的垃圾收集动作,因为大多数Java对象存活率都不高,
所以Minor GC非常频繁,一般回收速度也比较快。

(2)全局GC(major GC or Full GC):指发生在老年代的垃圾收集动作,
出现了Major GC,经常会伴随至少一次的Minor GC(但并不是绝对的)。
Major GC的速度一般要比Minor GC慢上10倍以上

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_老年代_05

GC日志信息详解

YGC相关参数:

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_java_06


FGC相关参数:

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_内存空间_07

GC四大算法

引用计数法
复制算法(Copying)
标记清除(Mark-Sweep)
标记压缩(Mark-Compact)
分代收集算法

如何判断Java中对象是否存活?

引用计数算法

引用计数算法是给每个对象设置一个计数器,当有地方引用这个对象的时候,计数器+1
当引用失效的时候,计数器-1,当计数器为0的时候,
JVM就认为该对象不再被使用,是“垃圾”了。

引用计数实现简单,效率高;但是不能解决循环引用问问题
(A对象引用B对象,B对象又引用A对象,但是A,B对象已不被任何其他对象引用),
同时每次计数器的增加和减少都带来了很多额外的开销,
所以在JDK1.1之后,这个算法已经不再使用了。

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_内存空间_08

根搜索方法

根搜索方法是通过一些GCRoots对象作为起点,从这些节点开始往下搜索,
搜索通过的路径成为引用链(ReferenceChain),
当一个对象没有被GCRoots的引用链连接的时候,说明这个对象是不可用的。
GCRoots对象包括:
虚拟机栈(栈帧中的本地变量表)中的引用的对象。
方法区域中的类静态属性引用的对象。
方法区域中常量引用的对象。
方法栈中JNI(Native方法)的引用的对象。

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_老年代_09

复制算法(Copying):适用于新生代

虚拟机把新生代分为了三部分:
1个Eden区和2个Survivor区(分别叫from和to),默认比例为8:1:1

一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),
这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。
对象在Survivor区中每熬过一次Minor GC,年龄 +1
当它的年龄增加到一定程度时(默认是 15
通过-XX:MaxTenuringThreshold来设定参数),就会被移动到年老代中。

因为新生代中的对象基本都是朝生夕死(被GC回收率90%以上),
所以在新生代的垃圾回收算法使用的是复制算法。

复制算法的基本思想就是将内存分为两块,每次只用其中一块(from),
当这一块内存用完,就将还活着的对象复制到另外一块上面。

我们来举个栗子,在GC开始的时候,对象只会存在于Eden区和名为from的Survivor区,
Survivor区to是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到to,
而在from区中,仍存活的对象会根据他们的年龄值来决定去向。
年龄达到一定值(默认15)的对象会被移动到老年代中,
没有达到阈值的对象会被复制到to区域。经过这次GC后,Eden区和from区已经被清空。
这个时候,from和to会交换他们的角色,也就是新的to就是上次GC前的from,
新的from就是上次GC前的to。不管怎样,都会保证名为to的Survivor区域是空的。
Minor GC会一直重复这样的过程,直到to区被填满,to区被填满之后,
会将所有对象移动到老年代中。

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_老年代_10

-XX:MaxTenuringThreshold,设置对象在新生代中存活的次数。
因为Eden区对象一般存活率较低,一般的,使用两块10%的内存作为空闲和活动区间,
而另外80%的内存,则是用来给新建对象分配内存的。
一旦发生GC,将10%的from活动区间与另外80%中存活的Eden区对象转移到10%的to空闲区间,
接下来,将之前90%的内存全部释放,以此类推。

优缺点

优点 :不会产生内存碎片,效率高。
缺点 :耗费内存空间。

如果对象的存活率很高,我们可以极端一点,假设是100%存活,
那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。
复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。

所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,
而且最重要的是,我们必须要克服50%内存的浪费。

标记清除(Mark-Sweep):适用于老年代

标记清除算法,主要分成标记和清除两个阶段,先标记出要回收的对象,
然后统一回收这些对象,如下图:

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_老年代_11

简单来说,标记清除算法就是当程序运行期间,若可以使用的内存被耗尽的时候,
GC线程就会被触发并将程序暂停,随后将要回收的对象标记一遍,
最终统一回收这些对象,完成标记清理工作接下来便让应用程序恢复运行。

主要进行两项工作,第一项则是标记,第二项则是清除。

标记:从引用根节点开始标记遍历所有的GC Roots, 先标记出要回收的对象。
清除:遍历整个堆,把标记的对象清除

优缺点

优点 :不需要额外的内存空间。
缺点 :需要暂停整个应用,会产生内存碎片;两次扫描,耗时严重。

简单来说,它的缺点就是效率比较低(递归与全堆对象遍历),
而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差劲。

而且这种方式清理出来的空闲内存是不连续的,这点不难理解,
我们的死亡对象都是随机分布在内存当中,现在把它们清除之后,
内存的布局自然会零碎不连续。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,
这又是一种开销。并且在分配数组对象的时候,需要去内存寻找连续的内存空间,
但此时的内存空间太过零碎分散,因此资源耗费加大。

标记压缩(Mark-Compact):适用于老年代

简单来说,就是先标记,后整理,如下图所示:

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_老年代_12

优点 :没有内存碎片。

缺点 :需要移动对象的成本,效率也不高
(不仅要标记所有存活对象,还要整理所有存活对象的引用地址)。
所以一般是结合使用

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_老年代_13

垃圾回收机制

分代收集算法

当前商业虚拟机都是采用分代收集算法,它根据对象存活周期的不同将内存划分为几块,
一般是把Java堆分为新生代和老年代,
然后根据各个年代的特点采用最适当的垃圾收集算法。

在新生代中,每次垃圾收集都发现有大批对象死去,只有少量存活,就选用复制算法,
而老年代因为对象存活率高,没有额外空间对它进行分配担保,
就必须使用标记清除或者标记压缩算法来进行回收。

总结

年轻代(Young Gen)
年轻代特点是内存空间相对老年代较小,对象存活率低。

复制算法的效率只和当前存活对象大小有关,因而很适用于年轻代的回收。
而复制算法的内存利用率不高的问题,
可以通过虚拟机中的两个Survivor区设计得到缓解。
老年代(Tenure Gen)

老年代的特点是内存空间较大,对象存活率高。

这种情况,存在大量存活率高的对象,复制算法明显变得不合适。
一般是由标记清除或者是标记清除与标记整理的混合实现。

(1)标记阶段(Mark) 的开销与存活对象的数量成正比。
这点上说来,对于老年代,标记清除或者标记整理有一些不符,
但可以通过多核/线程利用,对并发、并行的形式提标记效率。

(2)清除阶段(Sweep) 的开销与所管理内存空间大小形正相关。
但Sweep“就地处决”的特点,回收的过程没有对象的移动。
使其相对其他有对象移动步骤的回收算法,仍然是效率最好的。
但是需要解决内存碎片问题。

(3)整理阶段(Compact) 的开销与存活对象的数据成开比。
如上一条所描述,对于大量对象的移动是很大开销的,
做为老年代的第一选择并不合适。

基于上面的考虑,老年代一般是由标记清除或者是标记清除与标记整理的混合实现。
以虚拟机中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高。
而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器做为补偿措施:
当内存回收不佳(碎片导致的Concurrent Mode Failure时),
将采用Serial Old执行Full GC以达到对老年代内存的整理。

面试真题

GC四种算法哪个好?

没有哪个算法是能一次性解决所有问题的,因为JVM垃圾回收使用的是分代收集算法,
没有最好的算法,只有根据每一代他的垃圾回收的特性用对应的算法。
例如新生代使用复制算法,老年代使用标记清除和标记整理算法。
所以说,没有最好的垃圾回收机制,只有最合适的。
请说出各个垃圾回收算法的优缺点
(1)内存效率: 复制算法 > 标记清除算法 > 标记整理算法
(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
(2)内存整齐度: 复制算法 = 标记整理算法 > 标记清除算法。
(3)内存利用率: 标记整理算法 = 标记清除算法 >

JMM(Java Memory Model)

volatile是Java虚拟机提供的轻量级的同步机制,类似synchronized
JMM有三个特点:
JMM(Java内存模型Java Memory Model,简称JMM)
本身是一种抽象的概念并不真实存在,它描述的是一组规则或规范,
通过这组规范定义了程序中各个变量
(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
JMM关于同步的规定:
1线程解锁前,必须把共享变量的值刷新回主内存
2线程加锁前,必须读取主内存的最新值到自己的工作内存
3加锁解锁是同一把锁
由于JVM运行程序的实体是线程,
而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),
工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,
主内存是共享内存区域,所有线程都可以访问,
但线程对变量的操作(读取赋值等)必须在工作内存中进行,
首先要将变量从主内存拷贝到的线程自己的工作内存空间,然后对变量进行操作,
操作完成后再将变量写回主内存,不能直接操作主内存中的变量,
各个线程中的工作内存中存储着主内存中的变量副本拷贝,
因此不同的线程间无法访问对方的工作内存,
线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:

JVM2:堆参数调优(OutOfMemoryError),GC(Java Garbage Collection),引用计数算法,复制算法,标记清除,标记压缩,JMM_老年代_14

验证

package com.jane;

/**
* @author jane
* @create 2021-03-10 16:51
*/
public class JMM
{
/**
* JMM :可见性(就是一种通知机制)
*/
public static void main(String[] args)
{
MyNumber myNumber = new MyNumber();
new Thread(() ->
{
//暂停一会线程
try{Thread.sleep(3000);}catch (Exception e){}

myNumber.add();

System.out.println(Thread.currentThread().getName()+"update number:"+myNumber.number);
}, "A").start();

while (myNumber.number ==10 )
{

}
System.out.println("有人告诉了主线程值已经修改了");
System.out.println(Thread.currentThread().getName()+"程序终止");
}
}
class MyNumber
{
//这里加了volatile,就是有这个机制告诉别的线程主内存的数值已经修改
volatile int number = 10;
//int number = 10;

public void add()
{
this.number=20;
}
}
结果
Aupdate number:20
有人告诉了主线程值已经修改了
main程序终止

Process finished with exit code 0