JVM包含两个子系统和两个组件,两个子系统为Class loader(类装载器)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。
方法区:存储已被虚拟机加载的类元数据信息(元空间)
堆:存放对象实例,几乎所有对象实例都在这里分配内存
Java栈(虚拟机栈):虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息
本地方法栈:为虚拟机使用到的native方法服务
程序计数器:当前线程所执行的字节码的行号指示器
深入理解Java虚拟机(第二版):
类加载器ClassLoader
类加载器分为四种:前三种为虚拟机自带的加载器
- 启动类加载器(Bootstrap):负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类
- 扩展类加载器(Extension):由Java语言实现,负责加载java平台中扩展功能的一些jar包,包$JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目录下的jar包
- 应用程序类加载器(AppClassLoader):由Java实现,也叫系统类加载器,负责加载classpath中指定的jar包及目录中class
- 用户自定义加载器: Java.lang.ClassLoader的子类,用户可以定制类的加载方式
工作过程:
- 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成
- 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成;
- 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
- 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载
- 如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException
以上流畅就是所谓的双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上请求
优点:从安全性角度来看,双亲委派模型可以防止内存中出现多份同样的字节码
执行引擎ExecutionEngine
Execution Engine执行引擎负责解释命令,提交操作系统执行
本地方法栈Native Method Stack
它的具体做法是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库。
本地接口Native Interface
本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++程序,Java 诞生的时候是 C/C++横行的时候,要想立足,必须有调用 C/C++程序(蹭热度),于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是 Native Method Stack中登记 native方法,在Execution Engine 执行时加载native libraies。
目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用 Socket通信,也可以使用Web Service等。
PC寄存器Program Counter
即程序计数器
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,就是将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。
方法区Method Area
方法区被所有线程共享,所有字段和方法字节码,以及一些特殊方法如构造函数,接口代码也在此定义。
简而言之,所有定义的方法的信息都保存在该区域,此区属于共享区间。
静态变量+常量+类信息(构造方法/接口定义)+运行时常量池存在方法区中
但是实例变量存在堆内存中,和方法区无关
栈Stack
栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致,是线程私有的。8种基本类型的变量+对象的引用变量+实例方法都是在函数的栈内存中分配。
栈存储什么?
栈帧中主要保存3 类数据:
- 本地变量(Local Variables):输入参数和输出参数以及方法内的变量。
- 栈操作(Operand Stack):记录出栈、入栈的操作。
- 栈帧数据(Frame Data):包括类文件、方法等等。
栈运行原理:
栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法(Method)和运行期数据的数据集,当一个方法A被调用时就产生了一个栈帧 F1,并被压入到栈中,
A方法又调用了 B方法,于是产生栈帧 F2 也被压入栈,
B方法又调用了 C方法,于是产生栈帧 F3 也被压入栈,
……
执行完毕后,先弹出F3栈帧,再弹出F2栈帧,再弹出F1栈帧……
遵循“先进后出”或者“后进先出”原则
每执行一个方法都会产生一个栈帧,保存到栈(后进先出)的**顶部,顶部栈就是当前的方法,该方法执行完毕
后会自动将此栈帧出栈
常见问题栈溢出:Exception in thread "main" java.lang.StackOverflowError
通常出现在递归调用时或者方法调用过深时。
堆Heap
堆栈方法区的关系:
HotSpot是使用指针的方式来访问对象:
- Java堆中会存放访问类元数据的地址,
- reference存储的就是对象的地址
Java栈保存对象的引用地址
堆保存引用地址对应的对象信息
方法区保存对象对应的class类型
Java7之前:
一个JVM实例只存在一个堆内存,堆内存大小是可以调节的。类加载器读取类文件后,把类方法常量变量等信息放入堆内存中,保存所有引用类型的真实信息,以方便执行器执行。
堆内存逻辑分为三部分:
Young Generation Space 年轻代 Young/New
Tenure Generation Space 老年代 Old/Tenure
Permanent Space 永久代 Perm
三种JVM:
•Sun公司的HotSpot
•BEA公司的JRockit
•IBM公司的J9 VM
国内还有阿里巴巴的taobao JVM
其中JVM堆分为新生代和老年代
新生代
新生代是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器回收,结束生命。新生代又分为两部分: 伊甸区(Eden space)和幸存者区(Survivor pace) ,所有的类都是在伊甸区被new出来的。幸存区有两个: 0区(Survivor 0 space)和1区(Survivor 1 space)。当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。然后将伊甸园中的剩余对象移动到幸存 0区。若幸存 0区也满了,再对该区进行垃圾回收,然后移动到 1 区。那如果1 区也满了呢?再次垃圾回收,满足条件后再移动到老年代。若老年代也满了,那么这个时候将产生MajorGC(FullGC),进行老年代的内存清理。若老年代执行了Full GC之后发现依然无法进行对象的保存,就会产生OOM异常—“OutOfMemoryError”
如果出现java.lang.OutOfMemoryError: Java heap space异常,说明Java虚拟机的堆内存不够。原因有二:
(1)Java虚拟机的堆内存设置不够,可以通过参数-Xms(初始内存)、-Xmx(最大内存)来调整。
(2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。
老年代
默认15次GC后还存在的对象就会放入老年代,老年代对象比较稳定,不会频繁的GC
永久代
永久代是一个常驻内存区域,用于存放JDK自身所携带的Class,Interface的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收的,关闭 JVM 才会释放此区域所占用的内存。
如果出现 java.lang.OutOfMemoryError: PermGen space,说明是Java虚拟机对永久代Perm内存设置不够。一般出现这种情况,都是程序启动需要加载大量的第三方jar包。例如:在一个Tomcat下部署了太多的应用。或者大量动态反射生成的类不断被加载,最终导致Perm区被占满。
Jdk1.6及之前: 有永久代,,常量池1.6在方法区
Jdk1.7: 有永久代,但已经逐步“去永久代”,常量池1.7在堆
Jdk1.8及之后: 无永久代,常量池在元空间(Metaspace)
实际而言,方法区(Method Area)和堆一样,是各个线程共享的内存区域,它用于存储虚拟机加载的:
类信息+普通常量+静态常量+编译器编译后的代码等等,虽然JVM规范将方法区描述为堆的一个逻辑部分,但它却还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。
对于HotSpot虚拟机,很多开发者习惯将方法区称之为“永久代(Parmanent Gen)” ,但严格本质上说两者不同,或者说使用永久代来实现方法区而已,永久代是方法区(相当于是一个接口interface)的一个实现,jdk1.7的版本中,已经将原本放在永久代的字符串常量池移走。
为什么移除永久代?
- 内存是启动时固定好的,调优困难,太小容易出现永久代溢出,太大则容易导致老年代溢出
- 字符串存在永久代中,容易出现性能问题和内存溢出
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低
常量池(Constant Pool)是方法区的一部分,Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,这部分内容将在类加载后进入方法区的运行时常量池中存放。
元空间并不在虚拟机中,而是使用本地内存,默认情况下,元空间的大小仅受本地内存限制,可以通过以下参数调优:
-XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
-XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
-XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集
堆参数调优
常用JVM参数
参数 | 备注 |
---|---|
-Xms | 初始堆大小。只要启动,就占用的堆大小,默认是内存的1/64 |
-Xmx | 最大堆大小。默认是内存的1/4 |
-Xmn | 新生区堆大小 |
-XX:+PrintGCDetails | 输出详细的GC处理日志 |
Java代码查看jvm堆的默认值大小: |
Runtime.getRuntime().maxMemory() // 堆的最大值,默认是内存的1/4
Runtime.getRuntime().totalMemory() // 堆的当前总大小,默认是内存的1/64
设置JVM参数
程序运行时,可以给该程序设置jvm参数,不同的工具设置方式不同。
命令行:
java -Xmx50m -Xms10m HeapDemo
eclipse、IDEA:
在 Run Configuration 的 VM Options 中设置。
查看堆内存详情
public class Demo2 {
public static void main(String[] args) {
System.out.print("最大堆大小:");
System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");
System.out.print("当前堆大小:");
System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
System.out.println("==================================================");
byte[] b = null;
for (int i = 0; i < 10; i++) {
b = new byte[1 * 1024 * 1024];
}
}
}
执行前配置参数:-Xmx50m -Xms30m -XX:+PrintGCDetails
执行:看到如下信息
新生代和老年代的堆大小之和是Runtime.getRuntime().totalMemory()
开发中,eclipse使用MAT工具、IDEA分析Dump文件来定位OOM位置。
GC垃圾回收
JVM垃圾判定算法:
- 引用计数法(Reference-Counting)
- 可达性分析算法(根搜索算法)
GC垃圾回收算法:
- 复制算法(Copying)
- 标记清除(Mark-Sweep)
- 标记压缩(Mark-Compact),又称标记整理
- 分代回收算法(Generational-Collection)
垃圾判定
引用计数法(Reference-Counting)
引用计数算法是通过判断对象的引用数量来决定对象是否可以被回收
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的
优点:
- 简单,高效,现在的objective-c、python等用的就是这种算法。
缺点:
- 引用和去引用伴随着加减算法,影响性能
- 很难处理循环引用,相互引用的两个对象则无法释放
因此目前主流的Java虚拟机都摒弃掉了这种算法
可达性分析算法
这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。
在Java中,可以作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中的引用对象。
- 方法区中的类静态属性引用的对象。
- 方法区中的常量引用的对象。
- 本地方法栈中JNI(Native方法)的引用对象
垃圾回收算法
复制算法(Copying)
该算法将内存平均分成两部分,然后每次只使用其中的一部分,当这部分内存满的时候,将内存中所有存活的对象复制到另一个内存中,然后将之前的内存清空,只使用这部分内存,循环下去。
优点:
- 实现简单
- 不产生内存碎片
缺点:
- 将内存缩小为原来的一半,浪费了一半的内存空间,代价太高;如果不想浪费一半的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
- 如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。 所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费。
年轻代中使用的是Minor GC,这种GC算法采用的是复制算法(Copying)。
HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1:1,一般情况下,新创建的对象都会被分配到Eden区。因为年轻代中的对象基本都是朝生夕死的(90%以上),所以在年轻代的垃圾回收算法使用的是复制算法。
在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
因为Eden区对象一般存活率较低,一般的,使用两块10%的内存作为空闲和活动区间,而另外80%的内存是用来给新建对象分配内存的。一旦发生GC,将10%的from活动区间与另外80%中存活的eden对象转移到10%的to空闲区间,接下来,将之前90%的内存全部释放,以此类推。
标记清除(Mark-Sweep)
“标记-清除”(Mark Sweep)算法是几种GC算法中最基础的算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。正如名字一样,算法分为2个阶段:
- 标记出需要回收的对象,使用的标记算法均为可达性分析算法。
- 回收被标记的对象。
缺点:
- 效率问题(两次遍历)
- 空间问题(标记清除后会产生大量不连续的碎片。JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。)
标记压缩(Mark-Compact)
标记-整理法是标记-清除法的一个改进版。同样,在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是通过所有存活对像都向一端移动,然后直接清除边界以外的内存。
优点:
标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。
缺点:
如果存活的对象过多,整理阶段将会执行较多复制操作,导致算法效率降低。
老年代一般是由标记清除或者是标记清除与标记整理的混合实现。
分代回收算法(Generational-Collection)
从简单情况的时间复杂度来看:
内存效率:复制算法 > 标记清除算法 > 标记整理算法
内存整齐度:复制算法 = 标记整理算法 > 标记清除算法
内存利用率:标记整理算法 = 标记清除算法 > 复制算法
可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存,而为了尽量兼顾上面所提到的三个指标,标记整理算法相对来说更平滑一些,但效率上依然不尽如人意,它比复制算法多了一个标记的阶段,又比标记清除多了一个整理内存的过程
分代回收算法实际上是把复制算法和标记整理法的结合,并不是真正一个新的算法,一般分为:老年代和年轻代,老年代就是很少垃圾需要进行回收的,新生代就是有很多的内存空间需要回收,所以不同代就采用不同的回收算法,以此来达到高效的回收算法。
年轻代(Young Gen)
年轻代特点是区域相对老年代较小,对像存活率低。
这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对像大小有关,因而很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
老年代(Tenure Gen)
老年代的特点是区域较大,对像存活率高。
这种情况,存在大量存活率高的对像,复制算法明显变得不合适。一般是由标记清除或者是标记清除与标记整理的混合实现。
垃圾收集器
垃圾回收的具体实现:Serial收集器、 Parallel收集器、 CMS收集器、G1收集器。