该篇文章介绍了JVM,但是没有去深挖其中的一些知识点。但足够运用在业务代码中,且着重讲解现在常用HotSpot虚拟机。本篇文章大多数知识点借鉴了周志明老师的深入理解Java虚拟机一书。
简介虚拟机内存区域
- 程序计数器: 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的方位指示器,字节码指示器工作时是通过改变计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等线程的基础功能都需要依赖这个程序计数器。且由于java虚拟机的多线程是通过多个线程轮流切换并分配处理器执行时间的方式来实现的,在某一时间,一个处理器只会处理一条线程,为了就是执行时间到了之后恢复到正确的执行位置,每个线程都有独立的程序计数器,各线程间运行互不影响,独立存储,我们称之为线程独有的内存。
- java 虚拟机栈:与程序计数器一样,java虚拟机栈也是线程独有的空间,他的生命周期与线程相同。虚拟机栈描述的是java方法执行的内存模型:即是每个方法执行的时候都会创建一个栈帧用于存储方法相关的数据,如局部变量表,操作数栈,动态链接,方法出口等方法的相关信息。每一个方法执行开始到结束的过程,都对应者一个栈帧在虚拟机栈中入栈到出栈的过程。局部变量里存储java的各种基本类型,对象引用以及返回类型。
- java 本地方法栈:本地方法栈与虚拟机栈作用相似。只不过虚拟机栈执行的是java方法,而本地方法栈执行的是Native方法,即本地方法。他也是线程独有的,所以安全。
- java 堆:java 堆是虚拟机中内存最大的一块,且java堆是所有线程共享的区域。里面只存储对象实例,几乎所有的对象都是在该区域分配对象实例内存。我们暂且不关心他这么大区域为什么只存放实例,我们来了解下他如何分配区域。该区域可能为每一个线程都创建了分配缓冲区,创建缓冲区的目的是为了更好的回收实例也是为了更好的创建实例,因为在多线程的情况下是不安全的,可能你正在为A对象分配实例,B对象这时候抢过指针来分配实例,好在在TALB重试机制和内存提前分配好的实现下并不担心对象实例化失败的情况。扩展配置java堆的大小命令由-Xmx和-Xms控制。且java堆中分为新生代(新生代又分为伊甸园区和两个幸存者区,在下文有使用讲解)和老年代,主要是为了迎合分代收集算法的理念。
- 方法区:方法区和堆一样是线程共享的内存,实例由堆来保存了那么对象信息当然也需要了,那就是方法区了。但是我们了解的对象信息不仅仅是对象的数据结构,它存储的数据类型有:类信息,常量,静态变量,即时编译器编译后的数据等。该区域的回收机制不是很好。一般像我们所说的将引用赋予控制就是为了释放该区域的内存。
new 操作加载
上图简述了一下对象的创建过程,下面我会按照步骤来讲解一下对象大概的创建过程。
- 在如图所示的对象创建过程当中,线程接收到对象创建命令会去方法区的常量池中查找是否存在符号引用,并且检查这个引用代表的类是否已被加载解析和初始化过。如果没有,那就必须执行类加载过程。
- 在类加载完成之后就需要给新创建的对象分配内存了,对象所需的内存在加载的时候虚拟机便已经知道了,所以分配内存时他只需要把一块大小相等的内存在堆中划分出来。
- 这一步的方法运行是要好好讲一讲了,对象的常量和方法的局部变量存在着差异,且加载和初始化都略有不同,有没有遇到过你在类里声明变量不需要初始值,而在方法里没有设置初始值的话那就编译不通过。因为类的变量虚拟机会给他一个所谓的“零值”也就是初始值,但是局部变量如果没有初始值就会编译不通过。且常量和静态变量也是。既然是方法执行区,所以在调用方法是线程是安全的。
对象的访问
hotspot虚拟机使用的是直接指针访问,下面我讲一下大概的访问方式。还有一种句柄访问方式,二者各有优缺点,有兴趣的朋友可以自行去了解一下。
可以看到该存储方式类似于单向链表结构的存储,由堆中内存将实例数据与方法区的类型指针共同存储。至于句柄的访问方式,则是独立出一块内存只存储堆中实例的地址和对象类型数据的地址。二者之间句柄比直接指针访问多了一层代理的关系。则优点就是直接指针访问的方式少了一层代理中转,在多数据的情况下速度要优于句柄。但是句柄在对象被移动时,只要修改句柄内的对象地址即可,灵活性要优于直接指针。
堆栈溢出问题
堆溢出
堆溢出:如果实例超出堆内存最大限度的时候会抛出OutofMemoryError。避免堆自动扩展,使用-Xms(最小值),-Xmx(最大值)设置堆内存最大最小值。
我使用的是Idea所以下面给出修改堆内存的方法。
tips:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
public class HeapSpaceTest {
public HeapSpaceTest() {
}
public static void main(String[] args) {
List<HeapSpaceTest> lists = new LinkedList<>();
while (true) {
lists.add(new HeapSpaceTest());
}
}
}
java.lang.OutOfMemoryError: GC overhead limit exceeded
Dumping heap to java_pid7540.hprof ...
Heap dump file created [36648540 bytes in 0.165 secs]
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at oom.HeapSpaceTest.main(HeapSpaceTest.java:21)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)
Process finished with exit code 1
以上便是堆的溢出问题复现。如上所见,当实例数据大于堆设置的参数最大值时,会抛出OutOfMemoryError。认真看了看抛出的异常原来想要的是Memory overhead(内存溢出),没想到抛出了GC overhead按照字面的意思应该就是垃圾回收器回收不过来了吧。大概了解了堆内存溢出的问题。下面我们再搞下虚拟机栈溢出吧。
栈溢出
虚拟机栈: 如果方法内的定义了太多的本地变量大于栈内存大小时,就会抛出StackOverFlowError。
public class StackErrorTest {
private int stockLength;
public void addLength() {
stockLength++;
addLength();
}
public static void main(String[] args) {
StackErrorTest test = new StackErrorTest();
try {
test.addLength();
} catch (StackOverflowError e){
System.out.println(test.stockLength);
throw e;
}
}
}
所以可以看出当栈内存不足或者本地变量大于内存时,会抛出StackOverFlowError。
GC怎么判断和回收
在看GC回收时要搞清楚哪些需要回收哪些不需要回收,虚拟机中的线程私有区域与线程共存亡,线程结束了私有区域也就被回收所以私有区域内存并不需要我们来担心。而需要回收的是线程共有的区域,也就是生命周期与虚拟机存活时间相当的堆和方法区两块区域。堆是内存最大的一块区域,如何判断他是否还存活或者死亡?在虚拟机中死亡的意义在于确定在其他任何地方不会再使用的对象。方法区的回收机制可能比较于堆来说较为复杂,下面会一一讲解。
堆实例回收
- 计数算法: 看名字就知道,判断对象是否存活的核心思想是给对象添加个计数器,如果其他有一个地方引用他就加1,当引用失效时,计数器就减1,所以只要当计数器为0时这个对象就可以判定他是死亡的。计数算法实现简单判定效率也很高。但是他也有缺点,让我想起了多线程的“死亡拥抱”,如果两个类互相引用,各自的计数器就不可能存在0的情况了。
- GC ROOT可达性算法:我先来讲讲GC ROOT代表的是什么意思。其实他并不是虚拟机中的一种工作机制,而是将一些区域(某些类引用的地方)成为起始点,也就是GC ROOT的概念了。起始点大概概括为:虚拟机栈中引用的对象(方法里面的对象引用),方法区中类静态属性引用的对象(方法区中可是存在类信息,静态遍历和常量),方法区中常量引用的对象(即是类变量里的静态和常量引用的对象)。GC ROOT判定对象存活状态如下图,如果实例被GC ROOT引用那就是可存活,但是如果没有被引用那么就是可回收。缺点应该显而易见,在查询某对象是否应该被回收时他要去追踪是否被GC ROOT引用,效率要比前者低。
方法区类信息常量与静态变量的回收
很多人以为方法区内存储的常量与静态变量是没有垃圾回收的,准确来说在方法区的垃圾回收性价比很低,在堆中一次垃圾回收一般可以回收70%-95%的空间,而方法区内的成绩要远低于此。假设方法区中有一个常量“abc”,如果当前项目没有任何一个地方使用它,那么在有必要的情况下它是要被回收的,常量池的其他类,方法,字段的符号引用也是如此。
判定方法区是否可以回收
判定一个常量是否需要被回收比较简单,但是类的话会相对于比较复杂,需要满足三个条件才能算无用的类,可就是算满足了,也不能说无用就回收,也仅仅只能说是可以回收,而不是和对象一样不使用了就必然回收。是否回收,HotSpot提供了-Xnoclassgc进行控制,三个条件为:
- 该类在堆中的实例都已经被回收,堆中不存在该类的任何实例。
- 加载该类的classLoder已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾收集算法
下面介绍下几种常见的回收算法,并介绍下各自的优缺点。
标记-清除算法
这个算法算是比较基础了,后面的几种算法也是根据该算法改进的。理解名字就知道该算法是通过标记需要回收的对象,在标记完成后统一进行清除的基础算法,该算法不足的地方有两个:最显而易见的是清除之后产生的许多不连续的内存碎片,当后续需要分配内存较大的对象时,内存不能做到物以致用。二就是效率的问题,标记和清除的效率都不高。
复制算法
该算法解决了基础算法的效率问题也解决了内存碎片不连续的问题。实现概念主要是将内存区域划分为相等的两块区域,如果原来内存的一半使用完之后,然后就将存活的对象复制到另一块区域上去,然后一次性清理掉原来的那一半区域,该实现方式实现简单内存高效,完美解决了基础算法的效率和内存碎片问题,但是可使用的内存只有原来的一半,代价也太高了些。
但是这里讲解一下现在常见的虚拟机堆中的新生代回收算法基本都是复制算法。但是你也会说复制算法都已经把内存五五分成了,为什么还要使用这种低内存使用率的方法来实现。这里我们要说一下堆中新生代中的存活率是很低的,只有很少很少的部分能存活所以不需要按照1:1的比例来切割内存,所以JVM解决办法就是将新生代分配一个Eden和两个Survivor区域(8:1:1),每次取Eden和一个Survivor区域来使用复制算法到另一个Survivor区域,可能10%的内存也许会不够,这里会使用到方法区作为担保,如果内存不够就会从方法区“借”内存。所以这里新生代的复制算法只会“浪费”10%的内存。
标记-整理算法
在看完新生代如何运用复制算法后,心里应该有了个大概的概念了。但是应用新生代的对象存活率低的情况下,复制算法的确很优秀,但是要是应用在对象存活率很高的情况下,复制算法就要做大量的复制操作,效率会变低。且最关键的是如果像新生代一样不想浪费50%的内存,那还需要担保,以应对被使用的内存大量对象存活的情况,所以存活率很高的内存中就不能使用复制算法了。
标记-整理算法:和基础的标记-删除算法理念相同,只不过在标记完成后,不是直接对可回收对象进行直接的回收,而是把存活的对象都向一端移动,移动完成后,然后就清除其他边界外的内存。解决了内存碎片的问题。
tips:好像没有讲讲堆内存的老年代和新生代的区别以及大概的存储数据是什么,这里简单讲解一下,在堆中分配内存时,命令-Xmx与Xms是配置堆中最大最小的新生代内存大小,而老年代配置是-Xmn。再来说说他们两区域都是如何存储的,新生代区域是实例有限存储的地方,其中分为伊甸园区和两个幸存者区这不必多说。老年代则是主要存储一些“大对象”和长期存活的对象,大对象指的是那些需要大量连续空间的对象,典型的就是长字符串和数组。分代就是为了方便回收与存储。
以上就是虚拟机的介绍了,因为现在并涉及不到架构方面所以大概了解下,后续会四刷虚拟机相关经典书籍。有什么错误或者需要改正的地方欢迎留言。