文章目录
前言
一个进程对应一个JVM实例,一个JVM实例对应一个运行时数据区,而其中的所有线程都共享同一个堆和方法区,因此堆必须考虑线程安全的问题。
new关键字就是堆中创建对象的命令。【逃逸分析另论】
任何对象都是创建在堆上的。【看这篇总结的分析】
JVM启动实际:BootStrap
一、堆空间
1.1 分区
每一个java程序对应一个进程,也就是一个JVM实例。
可以通过查看堆空间的波动:
/**
* 演示堆内存分配
*/
public class Demo1 {
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
进行程序运行参数配置:
通过-Xms _M -Xmx _M
来设置堆的空间大小。
下载或者使用jdk8自带VisualVM可以查看堆空间的情况:
可以看到对堆空间参数的设置。
堆中的空间应该在逻辑上连续的区域,但是物理区域可以不连续。
逻辑上的连续可以保证堆读取数据高效。
由操作系统的知识可知,内存地址分为虚拟地址和物理地址,两者之间存在映射关系,因此物理区域是可以不连续的,操作系统可以帮助解决。
每个线程虽然共用同一个堆区,但是为了减少线程安全问题的发生,堆空间内部划分了许多个小区域,叫做TLAB Thread Local Allocation Buffer,即 线程私有缓冲区,每个线程具有自己独有的TLAB。
如下图:
虚拟机栈中的栈帧内部具有对象的引用,指向堆中的对象实例,该对象类型对应的类和方法都存储在方法区中,且存在一对一和共用的关系。
JVM规范提出任何对象都应该分配在堆中,但其实不是这样。
对象和数组可能永远都不会分配在栈上,栈中只保存指向对应对象的引用地址,指向堆中属于这个栈帧的堆区。
当栈帧销毁时,他对应的堆区域不会马上回收,对象还是会在堆区存储一段时间。当堆空间不足时,gc才会开始回收。
回收的宗旨是没有引用指向这个对象。
堆空间由于巨大,又从逻辑上分为三个区:
- 新生代/新生区
- 老年代/养老区/老年区
- 永久区(1.7之前) / 元空间(1.8以后)
要注意,元空间/永久区其实是方法区,因此强调是逻辑上的堆区。
1.7到1.8堆空间的变化是面试题。
即从永久区改名为元空间。
黄色部分为老年区,绿色为新生区,橙色为元空间。
查看垃圾回收细节:-XX:+PrintGCDetails
//注意大写和加号
1.2 设置大小
设置堆起始大小:-Xms _M
等价于 -Xx: InitialHeapSize _M
设置堆最大大小:-Xmx _M
等价于 -Xx: MaxHeapSize _M
- 前者并不是后者的缩写,而是两种命令。
- 两种命令都不涉及元空间的大小。
- 一条命令之间不需要空格,不同命令之间需要空格。
- 除了单位以外,剩下的字母严格区分大小写,首字母必须大写。
命令行/option查看堆空间的空间分配情况:
方式一:cmd下
- jps: 列出所有jvm进程的id;
- jstat -gc 进程id; 查看对应id的进程的分配情况。
方式二,option下:
-Xx: +PrintGCDetails
实际使用时,发现方式二已经被弃用,改用:-Xlog:gc*
注意最后地星号
case:
查看默认情况下堆空间大小,以及设置之后的堆空间大小。
tips:
- 默认情况下,堆空间的初始化大小为内存的64分之一,最大内存为物理内存的4分之一
- 因此可以从这个条件推出原始空间大小。
- 真正开发中初始内存需要和最大内存相等,避免开辟和回收空间带来的性能浪费。
public static void main(String[] args) {
int m = 1024 * 1024;
long initmemory = Runtime.getRuntime().totalMemory() / m;
long maxMemory = Runtime.getRuntime().maxMemory() / m;
System.out.println("-Xms:" + initmemory);
System.out.println("-Xmx: " + maxMemory);
long g = m * m;
System.out.println("The size of memory of the computer is :" + (double)initmemory * 64 / 1024.0);
System.out.println("The size of memory of the computer is :" + (double)maxMemory * 4 / 1024.0);
}
1.3 OOM
广义上的异常是Exception+Error, 通常面试上都是广义上的。
case:利用死循环查看OOM异常
让list不断添加字节数组,直到撑爆堆区。
static class Picture{
private byte[][] pix;
public Picture(byte[][] pix) {
this.pix = pix;
}
}
public static void main(String[] args) {
List<Picture> list = new ArrayList<>();
while (true) {
list.add(new Picture(new byte[1024][1024]));
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
调节堆区初始与最大大小都为500M。
打开VisualVM参看堆区变化,如下图:
可以看到,每当新生代区有部分增长时,接着就会被划分到老年区中。
同时,老年区内存不断上升,最终到达顶点,出现了OOM。
另外,可以看到S0与S1不会同时工作,此时S1工作,S0空闲。
可以在Sampler的Memory中查看什么玩意导致了OOM,如下图,byte[]占据了99%的内存,因此可以断定是他造成了堆溢出。
1.4 堆区划分
因为java对象的生命周期有长有短,参差不齐。
而GC是按区域扫描的,若是将长生命的对象和短周期的对象共同放到一起进行扫描,未免太过损耗GC的性能。
就相当于你不冷你爸妈还在一直关心你要不要加衣服一样。
因此,为了减少资源损耗,选择通过分区来实现。
长周期的对象存放在老年区,短周期的对象放在新生区。
而新生区又能继续划分为Eden(伊甸园区), Survivor1(幸存者1区,From区),Survivor2(幸存者2区,To区)。
Survivor区是指Eden中没有被销毁的短生命对象,没有被销毁自然就是幸存者了。
设置新生代与老年代的比例:
-XX:NewRatio=?
?填写的是老年代对于新生代大小的倍数。
默认情况下为1.
也可以通过cmd来查看,cmd命令为:
jinfo -flag NewSurvivorRatio 进程id
设置Eden 与 Suravivor区的的比例。
-XX:SurvivorRatio=?
?代表Eden对于Survivor的倍数。
默认为八比一,但是在实际运行时般都不是这样的,除非显示的指定了比例,否则真实情况是6.
但是在任何API或者统计中显示都是8,只有那个统计图和stat统计可以看出是六比一。
设置与关闭自适应的内存分配策略:
XX:+UseAdapativeSizePolicy
, +号就是开启,-号就是关闭
不能使得Eden/Suravivor回复默认的八比一。
设置新生代堆空间大小:
Xmn:_M
一般不设置,优先度低于XX:NewRatio
命令。
即设置了 NewRatio + Xms(堆大小),即使设置了Xmn也不会执行。
基本上所有的对象都是在Eden被制造出来的,之后才加入老年区;
巨大多数的对象都是在Eden销毁,80%的都是朝生夕死的。
二、 回收步骤
2.1 流程
(1)程序最开始时,新创建的对象都在Eden区。一段时间之后,Eden去空间不足,触发MinorGC,即YGC(YongGC)。YGC会回收Eden中已经成为辣鸡的对象,并将还在使用的对象存放到任一个空的Survivor区中,被选中的Survivor去称为From区,剩余的那个称为To【因此,From和To不是事先设置的,而是随机选中,轮流当的】。
这些幸存对象会被添加一个叫做Age的计数器,并置为1;
(2)程序继续运行,当到某一个时刻,Eden区又满了,会再次触发YGC,此时,若存在幸存对象,会放到之前没有存对象的Survivor区中【即To区】,Age置为1.
同时,YGC会顺带清理From区的辣鸡对象,并将幸存对象放到To区中,Age加一。
此时,空的区【原来的From】成为新的To,而原To成为新的From。
注意:
幸存者区不会主动地触发GC,他的GC都是由Eden区的GC顺带处理的。
(3)经过一段时间的运行,程序到达一个奇异的时间段:
幸存者区的某些幸存者对象经过大量的转移和判断【默认为十五次,这个临界值成为Threshold】,得到**晋升【Promotion】**的机会,将Age置为16,晋升到老年区。
在老年区中,对象的销毁频率将会降到极低,可以安享晚年了。
对象分配与回收的描述:
老年区的GC:MajorGC
MajorGC会在老年区内存不足时触发,若MajorGC的工作不能使得老年区空间不足得到缓解,则触发OOM;
OOM的条件是老年区内存不足!
对象回收的频率:
新生区 > 老年区 > 元空间
老师的比方是:
士兵晋升。
每个士兵经过摸爬滚打,会随着年限和考核逐步升级【AGE++】,一步一步提升最终成为军官,基本可以在军队里养老了【晋升】;
其中,有些士兵因为考核不通过,被退伍【GC回收】;
也有一些士兵因为作战勇敢,立下大功,一次生了许多级【即AGE的跃升,AGE可以因为一些特殊情况一次增加很多级】;
也有一些甚至直接当上军官,从此基本不用担心提前退伍了【直接晋升到老年代】;
也有一些军二代,刚一出生就进入了军官【出生就是老年代】;
从柱状图也能清晰地看到各个区空间的变化:
Eden区一直增长,直到某一刻GC之后,空间清空,接着又是同样的步骤;
Survivor0和1一直呈现你有我就没有这样的水深火热的僵局。
OldGen一直呈现增长,每次都在Eden突降和Survivor切换的时候上升。
几个特殊情况:
(1)新生区放不下;
进行YongGC, 若GC过后还是放不下,尝试放到老年区;若还是放不下,进行MajorGC,同(2)
(2)老年区放不下;
先进行MajorGc,若还是放不下,报OOM。
(3)幸存者区放不下;
直接放到老年区,剩下情况同(2);
2.2 回收分类
GC的区域主要有三类:
(1)新生代;(2)老年代;(3)方法区
JVM的回收根据回收的区域分为两类:
部分回收(Partial GC)与全部回收(FullGC).
部分回收主要有:
新生代回收:YoungGC/MinorGC, 回收Eden,S0与S1区
老年代回收:Old GC/MajorGC, 回收OldGen【只有CMS垃圾回收器会单独收集老年代】
混合收集:同时收集新生代与老年代的部分区域【只有G1 GC垃圾回收1器如此做】
全部回收:
新生代、老年代、方法区同时回收。
因为常常有人把FullGC和MajorGC等同起来,其实是不同的。
但是大部分情况下,收集老年区都不会真的只收集老年区,而是全部收集,也就是FullGC,这样的等同是有他的道理的。
因此当别人使用Full GC
的称呼的时候,要联系上下文分析他说的是老年代回收还是新生代回收。
2.3 虚拟机性能调优的主要目的及分代思想
主要是为了减少GC的操作。
GC时候会执行STW机制,即Stop The World, 即停止用户线程的工作。
这样会降低用户程序的执行效率。
通常进行MajorGC都伴随 一次YoungGC的操作,这叫做Parallel Scavenge策略。
MajorGC的耗费时间大约是MinorGC的10倍,因此要尽可能的减少MajorGC,这要求我们尽量少使用大对象,防止放不进去Eden而直接放进Old,确不是长期对象,带来频繁的OldGC的操作。
并不是不分代就不能用了,而是为了减少对不必要的对象的检查次数。
若长期对象和短期对象都放在一起,GC过程中会频繁对各种都一视同仁的检查,这样下来会使得整个的效率大幅度地域Eden区单独的GC。
2.4 内存分配策略
- 优先分配在EDEN区。
- 大对象可能会直接放到Old区。
- 长期存活的对象直接放到Old区。
- 若Survivor区有一半都是同一个年龄,则 大于这个年龄的对象直接放到老年区而不用等待年龄到达Threshold;
- 空间分配担保策略。在执行MinorGc前,会首先检查老年代能否大于当前新生代的总占用(因为新生代的内容最坏情况下,即都是长期对象的情况下,会全部放到老年代中,此时,若是老年代存储不够,和后面一样)又因为一般达不到最坏情况,在开启空间分配担保的情况下,GC会退而求其次,选择查看之前的GC中新生代去往老年代的平均空间大小,若老年代大于这个平均值,就进行MinorGC,若还是不大于他,就进行FullGC,即全部堆空间和方法区进行一次回收。
2.5 TLAB
ThreadLocalAllocationBuffer【感觉和Java中的ThreadLocal类有关】
每个线程除了共享堆区外,还各自享有一块自己的私人空间,即TLAB。TLAB在EDEN区
每次进行内存分配时,线程的数据会优先分配在这个私人空间上,由于这是每个线程独有的,因此不存在线程安全问题,分配时就能相对并行的执行,比分配共享区域时候的加锁策略要快多了,因此称为快速分配策略
设置快速分配策略,-XX:UseTLAB
,不用设置也是模式开启的!
在cmd中也可以看这个命令有无开启:通过jps得到线程号的情况下, 使用 :jinfo -flag UseTLAB 进程号
查看是否开启。
每个线程的TLAB大小默认占整个EDEN的1%,不能太大也不能太小:
太大:那Eden区就放不了几个TLAB
太小:那一个TLAB放不小一个线程的一个小对象的空间存储。
可以通过参数设置所占比例:TLAB WasteTargetPercnet
快速分配策略的示意图:
- 当字节码文件通过类加载子系统加载,解析,初始化,成功加载之后;
- 开始时进行TLAB分配,若分配成功,直接在TLAB创建对象;
- 若TLAB空间不足,分配不成功的话,会在EDEN进行分配,若分配成功,创建对象;
- 若EDEN空间不足,进行MinorGC【根据老年代剩余空间大小决定是进行MinorGC还是FullGC】
面试题:
进程中的堆空间一定都是线程共享的吗?
不是,每个线程都会在EDEN区拥有一个TLAB,TLAB是每个线程的私人空间,因此分配时无需考虑线程安全问题,因为无需再分配空间时加锁,分配效率非常高。
三、堆参数调优小结
在Oracle的JVM规范中,堆调优参数一共有六百多个
这里提几个常用的:
- 打印调优参数信息:
- 初始参数信息:
XX:PrintFlagsInitial
- 设置后的参数信息:
XX:PrintFlagsFinal
, 若你更改过设置,这个打印出来的信息的=
会变成:=
- 初始参数信息:
- 设置堆中各区域大小
- 设置堆初始大小、最大大小:
-Xms100m -Xmx100m
- 设置新生带大小【其优先度低于
上面那条 + 比例
的设置】:-Xmn 100m
- 设置堆初始大小、最大大小:
- 设置堆区中各项的比例:
- 设置Eden:Survivor:
-XX:SurvivorRatio=8
- 设置Old:New
-XX:NewRatio=2
- 设置Eden:Survivor:
- 设置晋升阈值:
SetTenuringThreshold=15
【Tenure:任职,任期】 - 设置打印GC信息:
- 打印信息信息:
-XX:PrintGCDetails
- 打印缩略信息:
-XX:PrintGC
等价于-verbose GC
【verbose:详细的,繁琐的】
- 打印信息信息:
- 设置是否开启空间分配担保:
-XX:HandlePromotionFailure
【即养老区不足分配,会进行一个FullGC】
SurvivorRatio的调优策略: 若Survivor的Ritio过低:**分区失去意义**。从Eden区幸存的对象因为不能保存到Survivor区,而去分配到养老区,而这些对象往往生命周期很低,因此会导致养老区频繁地GC,而养老区GC效率很低,会导致程序效率很低。 若Eden:Survivor设置过小,youngGC频率变高,性能也会下降。
空间分配担保。
四、空间逃逸
一个对象,若在堆中分配,需要去另外的内存开辟空间,并且在空间满了以后也需要特地去GC,因此资源耗费比较高。
若是在虚拟机栈区分配,就能保证在方法帧出栈的同时从内存销毁,少了开辟空间和GC的开销,性能会提升很多。
而如果这个对象在该方法帧之外的地方被引用或者创建,就不能销毁它。
一个对象的在方法中创建,并且在方法外也有使用,叫做空间逃逸
空间逃逸分析就是为了检测一个对象是否存在空间逃逸。
空间逃逸是默认开启的。
开启空间逃逸必须保证JVM是server模式,
而我们下载的JDK默认开了server模式。
如,若将sb对象返回,即sb发生了逃逸,改为toString(),返回的是String类的引用,sb只是在方法中创建和销毁,就没有发生逃逸。
逃逸的证据:对象实体有没有可能在方法外部被调用。
逃逸分析代码举例:
/**
* 逃逸分析
*/
public class Demo5_1 {
Demo5_1 demo5 = null;
public Demo5_1 getInstance() {
// 出现返回的对象,必定发生了逃逸
return demo5 == null ? new Demo5_1() : demo5;
}
public void trap() {
// 虽然的确在方法内部,而且没有返回值,但是由于对象实体在外部,因此发生了逃逸
Demo5_1 demo5_1 = getInstance();
}
public void setDemo5() {
// 操作外部变量,发生了逃逸
// 另一种情况:若demo5为静态的呢?仍然发生逃逸,对象触及到了方法外
this.demo5 = new Demo5_1();
}
public void inner() {
// 没有发生逃逸,可以使用栈上分配
Demo5_1 demo5_1 = new Demo5_1();
}
}
4.1 栈上分配
设置逃逸分析及栈上分配【若检测未逃逸,会进行栈上分配。】
-XX:+DoEscapeAnalysis
, 默认是开启的,若要关闭,将+换成-
验证栈上分配对性能的提升:
原理:
在外部方法中使用一个内部对象,在创建足够多的对象的情况下查看时间【System.currentTime】
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 1_000_000_000; i++) {
alloc();
}
System.out.println(System.currentTimeMillis() - start);
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private static void alloc() {
// 没有发生逃逸
User user = new User();
}
}
关闭逃逸分析:【-Xms100m -Xmx100m -XX:+PrintGC -XX:-DoEscapeAnalysis
】
可见,内存分配中出现了User对象,并且进行了垃圾回收。
开始逃逸分析:【-Xms100m -Xmx100m -XX:+PrintGC -XX:+DoEscapeAnalysis】
经过代码运行时间比较,由原本535ms下降到4ms,其提升之大可见一斑。【其实不是因为栈上分配,准确说HotSpot就没有栈上分配,看后面就知道了】
4.2 同步分析
另一个提升方法执行性能的措施是同步分析。
由于同步的耗费资源巨大,而程序员有时候没有注意同步锁的使用,使用一个无效的对象来作为锁对象,不但达不到同步效果,执行中还会因为同步而耗时。
同步分析会自动地检查同步锁中的无效锁,并直接将其消除。
称为同步省略或者锁消除。
下图的代码:
每个线程执行到这个方法时,都会创建一个hollis对象,因此,synchronized语句只会利用自己的hollis对象加锁,所有线程围绕不同的对象加锁,该方法就起不到同步的效果
同步省略过程由JIT编译器在分析字节码文件时自动执行,因此字节码文件上还是有加锁的,只是在执行指令时会被省略。
代码:
/**
* 同步分析
*/
public class Demo5_3 {
private static int count = 0;
public static void main(String[] args) {
method();
}
public static void method() {
Object hollis = new Object();
synchronized (hollis) {
for (int i = 0; i < 10000; i++) {
new Thread( () -> {
System.out.println(count++);
try {
Thread.sleep(59);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
}
}
IDEA已经给出警告了。
若执行代码,只打印到9800多次就停止了,说明之后有有多个线程抢占执行权时,互相礼让了。
查看字节码文件,发现字节码中有加锁的行为,证明锁消除是在分析字节码文件后执行了。
monitor【监控,监视器】
改成对唯一对象Class对象进行加锁,
这次还是差一个,不知道为毛。
也还有加锁行为:
4.3 标量替换
对于一个没有进行方法逃逸的对象,如果也在堆空间中进行分配就很耗费资源,划不来。
因此有了栈上分配和标量替换技术。
标量:指的是java中最小的数据单元,一般就是指8中基本数据类
聚合量:由标量和其他聚合量组成的单元,如java中的对象。
当一个聚合量(对象)可以被分解为若干标量,且根据逃逸分析,他没有逃逸出方法执行的范围,就不会选择在堆分配空间,而将聚合量拆分为几个标量分配在几个寄存器上。
可以看到,通过编译后的标量替换,一个原本是对象的程序,会被替换为一个单纯的标量打印,就可以将x,y分配在寄存器上而不是堆空间了。
开启标量替换:-XX:EliminateAllocations
注意:逃逸分析必须要开启,因为标量替换技术是基于逃逸分析的。
4.4 总结及否定之否定
实际上,逃逸分析技术并不成熟。
极端情况是:进行了逃逸分析,一个未发生逃逸的对象都没找到,白白损耗了逃逸性能。
但是,因为有标量替换的存在,逃逸分析的作用还是巨大的。
要注意,HotSpot目前还不支持栈上分配,之前的程序开启和关闭逃逸分析的效率提升几百倍,主要是因为进行了标量。
因为标量不算是对象,且现在方法区的常量池和静态变量都已经存储到了堆区(元空间爷在堆区),因此可以断言:对象都是分配在堆上的