jvm系列-04精通运行时数据区共享区域---堆
Posted huisheng_qaq
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了jvm系列-04精通运行时数据区共享区域---堆相关的知识,希望对你有一定的参考价值。
JVM系列整体栏目
内容 | 链接地址 |
---|---|
【一】初识虚拟机与java虚拟机 | https://blog.csdn.net/zhenghuishengq/article/details/129544460 |
【二】jvm的类加载子系统以及jclasslib的基本使用 | https://blog.csdn.net/zhenghuishengq/article/details/129610963 |
【三】运行时私有区域之虚拟机栈、程序计数器、本地方法栈 | https://blog.csdn.net/zhenghuishengq/article/details/129684076 |
【四】运行时数据区共享区域之堆、逃逸分析 | https://blog.csdn.net/zhenghuishengq/article/details/129796509 |
精通运行时数据区共享区域---堆
一,深入理解运行时数据区堆
1,堆空间概念
heap
堆属于是运行时数据区的一块空间,属于一块比较重要的一部分,并且该区域的数据属于线程共享。
⚽ 一个JVM实例只存在一个堆,堆也是java内存管理的核心区域
⚽ Java堆在启动的时候被创建,其空间大小也被确定,并且是JVM管理的最大的一块内存空间(可调节)
⚽ 堆可以处于物理上不连续的内存空间中,但是在逻辑上他应该是被视为连续的
⚽ 所有的线程共享JVM堆,在这里还可以划分为私有的缓冲区(Thread Local Allocation Buffer)
⚽ 在java虚拟机规范中,所有的对象实例以及数组都应该分配在堆上
⚽ 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置
⚽ 在方法结束之后,堆中的对象不会被立马移除,仅仅在垃圾收集的时候才会被移除
⚽ 堆是GC(Garbage Collection,垃圾回收器)执行垃圾回收的重点区域
2,堆内存空间
2.1,堆内存的细分
在JDK8之前,堆内存的逻辑主要分为:新生代+老年代+永久区
在JDK8以及JDK8之后,堆内存主要分为:新生代+老年代+永久区
jdk8的垃圾回收器如下,可以直接在jdk安装目录下,找到bin目录,然后打开这个 jvisaulvm.exe 文件,然后工具的在插件中安装一个 Visaul GC,就可以看到下面的界面,可知在jdk8中主要是分为Eden区,old区和Metaspace这三个区域,分别对应着新生代,老年代和元空间
也可以直接新建一个application运行并设置其对应jvm的参数,如设置初始大小和最大大小为10m,并在控制台上将这个堆信息打印出来
-Xms10m -Xmx10m -XX:+PrintGCDetails
在运行项目后,可以发现,PSYoungGen新生代,ParOldGen老年代,Metaspace元空间,其老年代所分配的内存最大。
hello
Heap
PSYoungGen total 2560 K, used 1086 K[0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
eden space 2048 K, 28 % used[0x00000000ffd00000, 0x00000000ffd91b68, 0x00000000fff00000)
from space 512 K, 98 % used[0x00000000fff00000, 0x00000000fff7e030, 0x00000000fff80000)
to space 512 K, 0 % used[0x00000000fff80000, 0x00000000fff80000, 0x0000000100000000)
ParOldGen total 7168 K, used 371 K[0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
object space 7168 K, 5 % used[0x00000000ff600000, 0x00000000ff65cf38, 0x00000000ffd00000)
Metaspace used 3520 K, capacity 4498 K, committed 4864 K, reserved 1056768 K
class space used 388 K, capacity 390 K, committed 512 K, reserved 1048576 K
2.2,堆大小的基本设置
Java堆区主要用于存储Java对象的实例,那么堆的大小在JVM启动的时候就已经设定好了,可以直接通过 -Xmx和 -Xms来设定堆的初始内存和最大内存,当堆的最大内存超过设置的 -Xms 最大内存时,则会直接抛出内存溢出异常。
-Xms : 设置堆空间(新生代+老年代)的初始内存大小
-X表示的是Jvm的运行参数
ms表示的是memory start
-Xmx:设置堆空间(新生代+老年代)的最大内存大小
并且在默认的堆空间中,其最大内存为物理电脑内存 / 4,初始内存为物理电脑内存 / 64
在实际开发中,更加建议将初始堆内存和最大的堆内存设置成相同的值,主要是省去堆空间频繁的扩容和释放带来的压力,从而增加这种资源的利用率。
2.3,堆大小的计算规则
堆大小主要分为新生代区和老年代区,新生代又分为eden区survive存活区,survive区有分为survive0和survive1区,对象只能存在期中的一个区域里面,每Minitor GC一次,就会从一个survive区到另外一个survive区,如果没有被清理掉,那么他的年龄就会+1,当年龄达到15时,就会从新生代中加入到老年代里面。
接下来在虚拟机中设置一个600m的堆大小,依旧是用刚刚那个程序
-Xms600m -Xmx600m
然后在main方法中运行之后,输入以下命令
jps : 查看进程号
jstat -gc 进程号
可以发现新生代有S0和S1的survive区和Eden区,老年代有old区。对应的容量分别是最大容量和已使用容量,如S0C表示的是survive0区的最大容量,S0U则表示的是survice0已使用的容量。后面也有对应的YGC和FGC发生的次数。
并且在计算总容量的时候,因为数据只能存在一个survive中,因此只计算一个survice的内存大小,所以有时计算出来的内容是小于实际设置的内容,但结果是对的,只是jvm在统计时只统计了一个survice存活区的大小。
3,堆对象
3.1,堆空间对象组成
在JVM中,java对象可以分为两类
一类是生命周期较短的瞬时对象,这类对象的创建和消亡都比较快
另一类周期长,某些极端情况下可以和JVM进程的生命周期保持一致
java堆细分可以分为新生代(YoungGen)和老年代(OldGen),年轻代又可以细分为Eden区和Survivor0空间和Survivor1空间,有时也被称为(from区和to区)
在这些对象中,也会对新生代和老年代的空间大小设置对应的比例,新生代和老年代的比例默认为1:2
当然也可以通过命令进行修改这个默认的比例,一般不会修改这个默认值,除非是知道这个老年代的对象偏多
-XX:NewRatio=2 : 表示新生代占1,老年代占2,新生代占1/3,默认比例
-XX:NewRatio=4 : 表示新生代占1,老年代占4,新生代占1/5
也可以直接通过命令查看,通过jps查出进程号之后,通过Jinfo去查看
jps
jinfo -flag NewRatio process(进程号)
除了老年代和新生代的对空间有比例之外,这个新生代中的Eden区和Survicor区也有对应的比例。如果是在Linux系统下,其比例为8:1 ;如果是在Linux系统下,其比例为6:1。
-XX:SurvivorRatio=8 : 表示eden区占8,Survicor区占1
-XX:SurvivorRatio=6 : 表示eden区占6,Survicor区占1
-XX:SurvivorRatio=4 : 表示eden区占4,Survicor区占1
这个比例也可以直接通过命令查看,通过jps查出进程号之后,通过Jinfo去查看
jps
jinfo -flag SurvivorRatio process(进程号)
因此可以得到一下结论
🎱 几乎所有的Java对象都是在Eden区被new出来
🎱 绝大多数对象的销毁在新生代就进行了
3.2,堆对象分配的一般过程
为新对象分配内存时一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配,在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片等问题。
🎱 在new一个对象时,会先将对象放在eden区,并且该去有大小限制
🎱 当eden区的数据量满了,程序还需要创建对象的时候,JVM的Minor Gc就会对Eden区的垃圾进行回收,当Eden区不再被其他对象引用的对象就进行销毁,被引用的对象就加载到Survivor0区中,然后此时eden区为空,再将新数据加载到eden区中
🎱 只有eden去满才会触发这个Minor GC,但是在触发这个垃圾回收时,不仅会回收Eden区的对象,也会回收这个Survivor区的对象
🎱 如果再次触发垃圾回收,就会将eden区中的未被引用的对象销毁,并且此时会检查survicor0区中的的数据,如果不存在有对该对象的引用,那么该对象也会被销毁,然后将eden区未被销毁的和survicor0未被销毁的一起加入到survicor1区,后面这两个区的对象往返移动
🎱 每将对象移动一次survicor区,其age年龄就会加1,初始值为1,阈值为默认为15,可以修改,当年龄达到阈值时,其再gc一次到达16,就会将对象加入到老年代的空间区域中
🎱 当老年代中的空间满了时,就会触发这个Full GC
总结
- 针对幸存者s0和s1的总结:复制之后有交换,谁不空谁是from,谁空谁是to
- 针对垃圾回收总结:频繁回收在新生区,很少在老年区回收,几乎不在永久区/元空间回收
3.3,堆对象分配的特殊过程
🌲 前两个步骤还是一样,在new一个对象时,会先将对象放在eden区,并且该去有大小限制
🌲 当eden区的数据量满了,程序还需要创建对象的时候,JVM的Minor Gc就会对Eden区的垃圾进行回收,当Eden区不再被其他对象引用的对象就进行销毁,被引用的对象就加载到Survivor0区中,然后此时eden区为空,再将新数据加载到eden区中
🌲 这里开始就不一样了,如果遇到的是一个大对象,可以在eden区存的下,但是在survivor区存活不下,那么直接将这个对象晋升为老年代
🌲 如果遇到的是一个超大对象,在eden区放不下,那么也会直接晋升为老年代,将这个超大对象存入到老年区中,如果老年代内存不够,那么就会触发Full GC,如果还是不够或者内存大小直接小于这个超大对象的大小,那么就会直接触发这个OOM,内存溢出错误。
3.4,代码示例
给对应的jvm堆配置的大小如下:-Xms600m -Xmx600m
,然后其对应的代码如下,就是写一个死循环,一直去创建Student对象,list作为一个对象的引用,只要list存在,里面的对象就不会被回收掉。
public class Student
//创建bu数组
byte bu[] = new byte[new Random().nextInt(1024*200)];
public static void main(String[] args)
List<Student> list = new ArrayList<>();
while (true)
list.add(new Student());
try
Thread.sleep(10);
catch (InterruptedException e)
e.printStackTrace();
接下来继续通过这个jvisualvm这个jdk自带的工具来查看堆内信息,一段时间之后,就发现出现了这个OOM。
如下图所示,Old区占400内存,年轻代占200内存,所以可知这个老年代区域:新生代区域为2:1;eden区大小为150,survicor为25,我这个是在windows系统下,因此也符合6:1,如果是linux系统,那就是8:1了。
而这个eden区到达峰值时,就表示eden区的数据满了,那么就会触发一次Minor GC,那么就会有数据存储在survivor中,再到达一次峰值,又会触发一次Minor GC,因此可以看到数据在Survivor的两个区中移动,而由于对象一直被引用着,那么不管是Full GC还是Minor GC都是不能将数据给移除的,因此这个young区的数据不能被清除,数据就会都存在old区中,那么old区的数据就是成直线上升的,当old区满了之后,那么就会直接触发这个OOM了。
4,初识GC
4.1,初识Minor GC,Major GC,Full GC
JVM在进行GC的时候,并非每次都对上面三个内存(新生代,老年代和方法区)区域一起回收,大部分的时候指的还是新生代。
而在HotSpot Vm中,GC又分为两种类型:部分收集和整堆收集
-
部分收集,不是完整的收集整个Java堆的垃圾收集,期中又分为:
新生代收集(Minor GC / Young GC):只是新生代的垃圾收集
老年代收集(Major GC / Old GC):老年代的收集器,很多时候Major GC和Full GC会混合使用,区要区分是整堆回收还是老年代回收
混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
-
整堆收集(Full GC),收集整个java堆和方法区的垃圾收集
4.1.1,新生代GC的触发条件
🐣 当新生代空间不足时,就会触发Minor GC,这里的新生代指的是Eden区,Survivor区是不会触发GC的
🐣 Java对象大多具有朝生夕灭的特性,所以Minor GC也非常频繁,回收速度也比较快
🐣 Minor GC会触发STW,就是暂停其他的用户线程,等垃圾回收结束,才会恢复运行
4.1.2,老年代GC的触发条件
🐣 指发生在老年代的GC,对象从老年代消失,即 Major GC
或者Full GC
发生了
🐣 出现了Major GC之后,经常会伴随着至少一次的Minor GC
🐣 Major GC的速度一般会比Minor GC慢10倍,其STW的时间甚至要更长
🐣 如果Major GC 之后,内存还是不足,就会直接报OOM了
4.1.3,Full GC的触发机制
🐣 调用System.gc()时,系统建议执行Full GC时,但是不必然执行
🐣 老年代或者方法区空间不足
🐣 Minor GC进入老年代的平均大小大于老年代的可用内存
🐣 直接加入到老年代的对象的大小大于老年代可用内存的大小
5,堆内存分配
5.1,基本的内存分配策略
🐋 将对象优先分配到Eden区
🐋 大对象直接分配到老年代,尽量避免程序中出现过多的大对象,如大树组,字符串等
🐋 长期存活的对象分配到老年代
🐋 动态对象年龄判断
🐋 空间分配担保
5.2,TLAB
Thread Local Allocation Buffer,即线程本地分配的一个缓冲区。
5.2.1,为什么要有TLAB
- 堆区是线程的共享区域,任何线程都可以访问到堆区的共享数据,好处是进程间的通信比较方便
- 由于对象的实例创建在JVM中非常频繁,因此在并发环境下,堆区划分的内存空间是线程不安全的
- 为避免多个线程操作同一个地址,需要使用加锁等机制,但是也会一定的效率
5.2.2,什么是TLAB
-
从内存模型角度来看,对Eden区域继续进行划分,JVM为每个线程分配了一个私有的缓冲区,存在Eden空间
-
多线程在分配内存时,使用TLAB可以避免一系列的线程安全问题,并提升一定的吞吐量
-
JVM将这个TLAB作为内存分配的首选,可以通过
-XX:UseTLAB
设置开启TLAB空间 -
默认情况下的这个TLAB内存只占1%,也可以通过
-XX:TLABWasteTargetPercent
设置其空间的百分比 -
一旦对象在创建这个TLAB空间分配失败,JVM就会通过加锁机制确保数据操作的原子性
因此在堆空间中,也不一定全部都是数据共享的,每个线程直接还存在着TLAB
6,堆空间参数设置
在堆空间的参数设置主要有如下
内容 | 链接地址 |
---|---|
-Xms | 初始堆内存 |
-Xmx | 最大堆内存 |
-Xmn | 新生代比例 |
-XX:PrintFlagsInitial | 查看所有参数的默认值 |
-XX:PrintFlagsFinal | 查看所有参数的最终值 |
-XX:NewRatio | 设置新生代和老年代堆占比 |
-XX:SurvivorRatio | 设置s区在Eden区占比 |
-XX:MacTenuringThreshould | 设置新生代垃圾的最大年龄 |
-XX:PrintGCDetails | 输出详细GC日志 |
-XX:HandlePromotionFailure | 是否设置空间分配担保 |
如设置堆空间的初始大小和最大大小:-Xms1024M -Xmx1024M
7,逃逸分析(重点)
在java虚拟机中,对象是在Java堆内存中分配的,但是有一种特殊的情况,那就是经过了逃逸分析后发现,如果一个对象并没有套溢出方法的话,那么就有可能被栈上分配,这样就可能被优化成栈上分配。这样就无需进行堆上分配,也就无需进行垃圾回收了。
7.1,栈上分配
如何将堆上的对象分配到栈上,需要使用到逃逸分析的手段,这是一种可以有效的减少Java程序中同步负载和内存压力的跨函数全局数据流分析算法。
通过逃逸分析,虚拟机的编译器可以分析一个新的对象的引用使用范围从而决定是否要将这个对象分配到堆上,逃逸分析的基本行为就是分析对象的作用域:
🎈 当一个对象在方法中被定义之后,对象只在方法内部被使用,则没有发生逃逸
🎈 当一个对象在方法中被定义之后,他被外部所引用,则认为发生逃逸
//没有发生逃逸分析
public Student test1()
//随着入栈出栈,该对象被销毁,对象分配在栈中
Student stu = new Student();
return null;
//发生了逃逸分析
public Student test2()
//该对象被外部所引用,对象分配在堆中
Student stu = new Student();
return stu;
在实际开发中,能使用局部变量的,就不要使用在方法外定义,也不要考虑静态等问题,这也是堆优化的策略之一
开启逃逸分析的命令:-XX:+DoEscapeAnalysis
,关闭则是-XX:-DoEscapeAnalysis
7.2,同步省略
除了这个栈上分配对象之外,JIT即时编译器也可以借助逃逸分析来判断同步块所使用的锁对象是否只能被一个线程访问而没有发布到其他线程,如果没有,那么这个JIT编译器就会取消堆这部分代码的同步,这样就能大大的提高并发性能,这个取消同步的过程就叫同步省略,又名锁消除
public void test()
Student stu = new Student();
//发现每次调用该方法锁的根本不是同一个对象,因此会将这个锁消除
synchronized(stu)
System.out.println("helloi stu");
7.3,标量替换
逃逸分析也能实现这个标量替换,标量指的就是无法再分解的更小的数据,如Java原始的数据类型就是标量,而还可以继续分解的对象化就被称为聚合量,java的对象就被称为聚合量,他为他还可以分解成标量或者其他聚合量
//聚合量是由标量和聚合量组成
public class User //聚合量
int age; //标量
String name; //标量
Account account; //聚合量
public Account //聚合量
Double money; //标量
而方法的局部变量是存在栈中的,在JIT编译期间,如果发现一个对象不被外界访问,那么结果这个JIT的优化,就会将这个对象拆分成一个个标量,即对应的局部变量,存储在栈帧里面。如这个Account对象,只有一个money这一个局部变量存储在栈帧中,而这个对象不存在,这个行为就被称为标量替换
标量替换的开启如下:-XX:EliminateAllocations
,默认是打开的,允许将对象打散分配在栈上。
8,堆空间是分配对象的唯一选择吗(重点)
答案是:是的!在HotSpot虚拟机确实是这样子
上面7中,重点是分析了一下这个逃逸分析的,里面是有栈上分配和标量替换的,栈上分配,不就是将对象分配在栈上面,随着方法的入栈出栈而销毁的吗?
首先,逃逸分析这项技术到如今是一门不成熟的技术,其根本原因就是无法保证逃逸分析的性能销毁是否一定高于他的性能,虽然通过逃逸分析可以实现标量替换、栈上分配、锁消除等,但是逃逸分析自身也是需要进行一定系列的复杂分析的,这其实也是一个相对耗时的过程。举个极端的例子,就是在开启逃逸分析,经过一顿分析之后,发现没有一个对象是不逃逸的,都被外面所引用着,那么这个逃逸分析的分析过程就被白白的浪费掉了。
其次,从逃逸分析的理论上来说,py,c++都是通过堆栈来存储对象,所以逃逸分析确实是可以用的,并且在jvm内部,逃逸分析也是一项主要的调优手段。但是,栈上是否会分配内存来保存那些未被逃逸的对象,这取决于JVM设计者的选择,而在官方的HotSpot虚拟机规范中明确指出,该虚拟机中并没有这么做,可以在逃逸分析的相关文档中查看,因此也可以明确对象的实例都是创建在堆上的。
最后通过一段代码来说明:
其jvm相关参数配置如下,主要是通过-XX:-DoEscapeAnalysis
的加减号控制是否开启逃逸分析
//未开启逃逸分析
-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
//开启逃逸分析
-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
其代码如下
public static void main(String[] args)
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++)
createUser();
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start) + "ms");
try
Thread.sleep(10000);
catch (InterruptedException e)
e.printStackTrace();
public static void createUser()
//对象未发生逃逸
User user = new User();
可以发现不开启逃逸分析创建10000000个对象需要77ms,而开启这个逃逸分析可以发现只需要4ms。并且可以通过这个通过jvisaulVm发现在开启逃逸分析之后,stackAllocation的个数是1000万个,而开启逃逸分析之后的个数只有几百甚至几十万个,说明对象此时是随着方法入栈出栈而销毁了。那说明逃逸分析确实是可以将对象不分配在堆上面的,但是HotSpot又明确说了不会在栈上分配内存,因此只剩下一种解释:标量替换,如果将这一个个对象拆分成局部变量,然后通过局部变量存储在栈帧上,从而代替这里的对象,然后随着栈帧的创建而创建,随着栈帧的销毁而销毁。
总而言之就一句话,逃逸分析理论确实可以存储在栈上,但是在HotSpot虚拟机中还是只有堆分配对象,而HotSpot是通过逃逸分析以及标量替换来实现栈上分配的,但是栈上分配的并不是像堆一样完整的对象,而是组成对象的各个标量
jvm系列-05精通运行时数据区共享区域---方法区
JVM系列整体栏目
内容 | 链接地址 |
---|---|
【一】初识虚拟机与java虚拟机 | https://blog.csdn.net/zhenghuishengq/article/details/129544460 |
【二】jvm的类加载子系统以及jclasslib的基本使用 | https://blog.csdn.net/zhenghuishengq/article/details/129610963 |
【三】运行时私有区域之虚拟机栈、程序计数器、本地方法栈 | https://blog.csdn.net/zhenghuishengq/article/details/129684076 |
【四】运行时数据区共享区域之堆、逃逸分析 | https://blog.csdn.net/zhenghuishengq/article/details/129796509 |
【五】运行时数据区共享区域之方法区、常量池 | https://blog.csdn.net/zhenghuishengq/article/details/129958466 |
运行时数据区共享区域---方法区
一,运行时数据区共享区域—方法区
1,方法区的基本概述
方法区和堆一样,也是属于运行时数据区中的共享区域,并且也属于重要的一个内存空间,该空间主要是配合堆栈一起工作。
如下面这行代码,new User就是存在Java堆中,第一个User就是存在方法区中,第二个user就是作为局部变量表存储在栈中。
User user = new User();
#方法区:User
#栈:user
#堆:new User();
《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择区进行垃圾回收或者进行压缩”。但是对于HotSpot虚拟机而言,方法区还有一个别名就叫做Non-Heap(非堆),目的就是要和堆分开。因此,方法区可以看做是一块独立于Java堆的内存空间
🎄 方法区和java堆一样,属于是各个线程共享的区域
🎄 方法区在Jvm启动的时候被创建,他的物理内存和堆一样可以是不连续的
🎄 方法区的大小和堆空间一样,可以选择固定大小或者可扩展
🎄 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误,如加载大量的第三方jar包,Tomcat部署的工程过多,大量的动态反射类等
🎄 关闭JVM之后,就会释放这个区域的内存
2,方法区的演进过程
这里主要是针对HotSpot虚拟机,在JDK8以前,习惯将方法区称为永久代;从JDK8开始,使用了这个元空间取代了永久代。就相当于把这个方法区当成是一个接口,而永久代和元空间就是该接口的具体实现。
In JDK8,classes metadata is now stored in the navite heap and this space is called MetaSpace
方法区和这个永久代并不等价,《Java虚拟机规范》对如何实现方法区,不做统一的要求。
到了这个 JDK8 之后,终于完全废弃了永久代的概念,改用JRockit、J9一样在本地内存中实现的元空间来代替。
元空间的本质和永久代类似,都是JVM规范中方法区实现的,不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用的是本地内存。
永久代和元空间二者不只是名字变了,内部结构也调整了,根据《java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常。
3,方法区大小设置与OOM
方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。
3.1,方法区内存大小的分配
在jdk1.8之前
🎄 可以通过 -XX:PermSize
来设置永久代初始的分配空间,默认值为20.75M
🎄 通过 -XX:MaxPermSize
来设置永久代最大的分配空间,32位机器默认是64M,64位机器为82M
🎄 当JVM加载的类信息容量超过了这个值,会报异常OutOfMemoryError:PermGen
在jdk1.8及以后
🎄 元数据区大小可以使用参数 -XX:MetaspaceSize
和 -XX:MaxMetaspaceSize
设置原始值和最大值
🎄 默认值依赖平台,windows下 -XX:MetaspaceSize
为21M,-XX:MaxMetaspaceSize
为-1,即没有限制
🎄 默认情况下,虚拟机会耗尽所有内存,如果元数据区溢出,虚拟机会抛异常OutofMemoryError:Metaspace
🎄 当内存高于设置的21M时,就会触发Full GC,Full GC就会卸载掉没用的类
🎄 因此建议将这个初始内存设置一个相对较高的值,以免频繁触发Full GC
3.2,OOM的解决方案
- 要解决这些OOM异常或者heap space异常,一段手段是通过内存印象分析工具堆dump出来的堆转存储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要分清楚是出现了内存泄漏还是内存溢出。
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联。在掌握了泄漏对象的信息,以及GC Roots引用链之后,就可以准确的定位到泄漏代码的位置了。
- 如果不存在内存泄漏,换句话就是说内存中的对象确实是还活着,那么就应该检查虚拟机的参数,与机器内存相比看是否还可以调大,从代码上检查是否存在某些对象的生命周期过长,持有时间过长等情况,从而减少运行期间的内存消耗。
4,方法区的内部结构
4.1,方法区存储数据概述
在将 .java
文件编译成 .class
字节码文件之后,这个字节码文件是需要存储的,而类本身的一些信息,则需要存储在这个方法区里面,除了类信息之外,这个运行时常量池也是存储在这个方法区里面的。
在《深入理解Java虚拟机》这本书中,对方法区存储内容的概述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。 但是随着JDK的不断迭代,其内存存储的东西也会有着稍小的变化。
类型信息:对加载的类型,包括类class、接口interface、枚举enum、注解annotation等,JVM必须在方法区中存储以下类型
♟ 这个类型的完整有效名称,全名就是 包名.类名
♟ 直接父类的完整有效名,interface和Object都是没有父类的
♟ 这个类型的修饰符,如public、static、final、abstract
♟ 直接接口的一个有序列表,如这个类可能实现多个接口
属性信息:域的相关信息主要包括以下:域名称、域类型、域修饰符等
方法信息:方法信息主要包括一些方法名称、返回类型、参数的数量和类型、方法的修饰符、方法的字节码、局部变量表以及其大小、异常表等
4.2,static final
在静态变量中,一般是随着累的加载而加载,他们成为类数据在逻辑上的一部分,并且类变量被类所有的实例共享,即使类实例不存在也可以进行访问。
而被声明的final的类变量的处理方法则不同,该变量在编译阶段就会被分配了;而没有被声明final的类变量,在准备阶段进行初步的赋值,在初始化阶段进行一个最终的赋值。
如写一个Java测试类,定义一个被final修饰的类变量和不被final修饰的类变量,并且这个类型为基础数据类型
public class Test
public static final int i = 10;
public static int k = 20;
public static void main(String[] args)
System.out.println(i+k);
然后在编译好的文件中,输入反编译命令,并将最终输出的文件加载到zhs.txt文件中
javap -v -p Test.class > zhs.txt
接下来重点分析这两个变量,可以发现这个加了final修饰的i,在编译阶段就进行了分配,赋值为10;而没有加final修饰的k,在编译阶段没有赋予默认值,而是在准备阶段赋值的默认值,在初始化阶段赋予的最终值
public static final int i;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 10
public static int k;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC
4.3,运行时常量池
4.3.1,什么是常量池
一个有效的字节码文件除了包含类的版本信息、字段、方法以及接口等描述信息之外,还包含一项重要的信息,那就是常量池表(Constant Pool Table),其中包括各种字面量和对类型,字段和方法的符号引用,如下
Constant pool:
#1 = Methodref #6.#27 // java/lang/Object."<init>":()V
#2 = Fieldref #28.#29 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Class #30 // com/fc/v2/util/Test
#4 = Fieldref #3.#31 // com/fc/v2/util/Test.k:I
一个Java源文件在编译之后会产生一个字节码文件,而字节码文件需要数据支持,通常这种数据量很大不能直接存储到字节码文件里面,因此就通过常量池的方式,提前将数据存储在常量池中,然后根据引用去获取对应的数据,如在栈帧的 动态链接 就是通过这种方式来获取数据的。
在常量池中存储的数据类型主要有:数量值,字符串值,类引用,字段引用,方法引用
常量池就可以看做成是一张表,虚拟机指令根据这张常量表找到执行的类名、方法名、参数类型和字面量等类型。
4.3.2,什么是运行时常量池
上面提到了常量池,常量池是字节码文件的一部分,用于存放编译期生成的各个字面量和符号引用,这部分内容在类加载之后存放到方法区的运行时常量池中;而运行时常量池是属于方法区的一部分,接下来详细的描述一下上面是运行时常量池
⚽ 在将类和接口加载到虚拟机之后,就会创建对应的运行时常量池
⚽ JVM会为每个已加载的类型都维护一个常量池,池中的数据可以通过索引访问
⚽ 运行时常量池包含多种不同的常量,包括编译期就已经明确的数值,如栈帧的大小,以及运行期间才能获取到的方法或者字段引用,此时不再是常量池中的符号地址#,而是具体的真实地址。
⚽ 运行时常量池具备动态性,如实际大小可能比计算的大小大
⚽ 当创建接口或者类的运行时常量池时,如果构造的运行时常量池所需要的内存空间超过了方法区所能提供的最大值,则JVM就会抛出OutOfMemoryError异常
5,方法区的使用
接下来查看一段简单的代码,如下
/**
* @author zhenghuisheng
* @date : 2023/4/4
*/
public class Test
public static void main(String[] args)
int x = 500;
int y = 100;
int a = x/y;
int b = 50;
System.out.println(a+b);
然后编译之后,通过jclasslib插件查看对应的字节码,在前面的章节又讲如何安装使用
其对应的字节码文件如下
0 sipush 500
3 istore_1
4 bipush 100
6 istore_2
7 iload_1
8 iload_2
9 idiv
10 istore_3
11 bipush 50
13 istore 4
15 getstatic #2 <java/lang/System.out>
18 iload_3
19 iload 4
21 iadd
22 invokevirtual #3 <java/io/PrintStream.println>
25 return
这个具体的操作流程如下,由于没有new对象,因此在下图中暂时先不展示堆空间。
首先程序计数器的记录的地址为0,并将这个500这个数值压入到操作数栈中。
然后就是这个istore_1,将操作数栈中存储的值存储到本地变量表中。而这个本地变量表,如果是实例方法或者是构造方法,第一个数据存储的应该是this,而这里的是static的静态方法,因此第一个slot存储的不是this。
后续的操作和上面的一样,这个iload_1就是将数据从本地变量本中取出来放到操作数栈顶,iload_2原理一样,然后结果这个idiv除法运算,将结果5存放在本地变量表3的位置
随后就是将50入栈,也存储到本地变量表中4的位置,然后通过这个getstatic #2,就是获取常量池中的#2的位置,然后最终可以获取到这个System类,out类和对应的type属性,然后加载到方法区中。如果这些类或者属性在方法区中存在就不会进行加载,如果不存在就会将这些加载到方法区中,然后将这些#等符号的间接引用变成地址的直接引用。
然后通过这个iload3和iload4将本地变量表的数据加载到操作数栈中
然后再经过iadd计算,再调用这个invokevirtual #3的符号引用,最终可以定位到一个打印操作,最终return结束
通过上图可以发现,无论是在哪一个操作,程序计数器都会指向对应的执行位置。
6,方法区的演进细节
在虚拟机中,只有HotSpot虚拟机才有永久代,JRockit和J9是不存在永久代的概念的。在HotSpot虚拟机的方法区变化如下(方法区是一种概念,永久代和元空间属于具体实现):
- 在jdk6及以前:有永久代,静态变量存放在永久代上面
- 在jdk7中:有永久代,但逐步去除,字符串常量池、静态变量保存在堆中
- 在jdk8及以后:无永久代,类型信息、字段、方法、常量保存在本地内存的元空间中,字符串常量池和静态变量在堆中
6.1,方法区的具体实现以及内部组成的演进
jdk6的方法区的组成如下,也称为永久代,其静态变量,运行时常量池,字符串常量池等都是保存在这个永久代的里面,并且字符串常量池是属于运行时常量池的一部分
而在jdk7开始,就将字符串常量池和静态变量存放在堆里面
而从jdk8开始,永久代已经不存在了,取而代之的是本地内存的 元空间,会将运行时常量池,类信息等全部存储在本地内存中,并且静态变量和字符串常量池都是存储在堆中
在官方文档中https://openjdk.org/jeps/122,也提到过这个删除这个永久代的动机,如下,主要就是说参考了这个 JRockit 内部的实现,这样用户可以不必自己去配置这个永久代。这样这块空间可以不用jvm本身去管理,从而交给本地内存区实现。
This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.
其实这项改动还是很有必要的,接下来从两个方面来说明为啥要替换
- 永久代的空间大小设置是很难确定的,在某些场景下,如果动态的加载类过多,就很容易出现OOM,比如一些Web项目。而元空间和永久代之间最大的区别在于:元空间不在虚拟机中,而是使用的是本地内存,因此元空间的大小只受本地内存限制
- 永久代的调优比较困难
6.2,字符串常量池为何要存储到堆中
在jdk7的时候,将静态变量和字符串常量池都存储到了堆中,其主要原因是在永久代中,其触发的回收效率很低,在full gc的时候才会触发。而full gc的老年代空间不足,或者永久代空间不足时才会触发,因此这就导致了这个字符串常量池的回收率不高。
而在如今的开发中,可能会创建大量的字符串,如果还是存储在方法区内部,那么其回收率会比较低,很容易导致永久代的内存不足,因此选择将这个字符串常量池存放到堆中,这样就可以快速的实现内存回收。
7,方法区的垃圾回收机制
在运行时数据区中,方法区又被称为non-heap,就是非堆的意思。并且在《java虚拟机规范》中描述,对方法区中的约束是非常宽松的,提到过不要求虚拟机在方法区中实现垃圾回收
一般来说这个区域的回收效果比较难令人满意,但是有时又确实是有必要的,因此在HotSpot虚拟机中,方法区的垃圾回收主要是回收两部分内容:常量池中废弃的常量和不再使用的类型
常量回收的策略就是只要该常量没有被任何地方引用,就可以被回收,回收废弃常量和回收Java堆的对象非常类似
类型回收的条件相对比较苛刻,需要同时的满足以下三点条件:
- 该类的实例被回收,该类以及对应的子类在堆中不存在
- 该类的类加载器已被回收
- 该类的Class对象没有任何地方被引用,无法在任何地方通过反射访问到该类
满足这三点条件也不是一定会进行回收,而是可能被允许回收。在大量的使用反射、动态代理、CGLib这些字节码框架,动态生成Jsp等这些场景中,通常需要java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的压力。
以上是关于jvm系列-04精通运行时数据区共享区域---堆的主要内容,如果未能解决你的问题,请参考以下文章
Jvm(12),运行时数据---共享区---jvm堆空间总览
jvm系列-03精通运行时数据区私有区域---虚拟机栈程序计数器本地方法栈