看完《深入理解Java虚拟机》一书,做了一些简单总结,其中复杂的部分自己也没有搞懂,所以就不发表任何总结!
Java虚拟机从字面意思直接理解就是运行Java的虚拟机器,既然是虚拟的,那么就是从物理层面来说是不存在于实际的一个机器,它不像电脑这种机器,是实际存在的,而是人们想象的一个机器,因为它能像机器一样做机器可以做的事情。
我们都知道Java语言是一种高级语言,我们可以从Java语言的特性中进行分析,Java特性包括面向对象、平台独立性、可移植性、支持多线程等。从列出的特性中我们可以从可移植性进行分析。
可移植性可以理解为Java代码可以在在一台机器上运行,也可以将代码复制到其它机器上运行,最终代码执行结果相同。而保证这个可移植性就要保证Java语言在不同机器上的各个类型是相同的,比如在32位机器上御64位机器上不会对Java代码造成影响。
C语言在32位和64位机器上类型长度是不同的,例如long类型在32位机器中长度为4,在64位机器中长度为8;但是Java语言不会存在这种问题。在32位和64位机器中,类型长度都是相同的。
而为了保证Java语言中类型的不变性,就将Java语言运行在Java虚拟机中,通过Java虚拟机编译Java代码,识别Java语言中的各个类型,最终再转换为机器可以识别的语言。所以Java虚拟机的作用主要是编辑Java代码,将编译好的Java代码转换为机器可识别的语言等。
所以本质上Java虚拟机就是一套编写好的代码,这个代码处于运行状态时可以编译Java代码,实现像机器一样的功能。
我们现在最熟悉的Java虚拟机应该是HotSpot VM,这是JDK1.3之后默认的虚拟机。现在通过查看Java虚拟机模型也可以看到该默认的虚拟机。
通过查看Java VisualVM可以看到默认的JVM就是HotSpot VM。
一下会通过JVM来代替Java虚拟机。
说完了概念再来说一下JVM的内存区域,既然JVM也是一套代码,那么这套代码也有自己的架构,通过架构我们才可以分析JVM的各个点是做什么的!
内存区域图如下:
堆是JVM的内存区域中占据容量最大的一块区域,它是线程共享的一块区域,主要存放的数据为对象。并且平时我们创建的对象都是首先在堆上进行分配空间的,一个对象占用多少内存都是在首先在堆中来进行分配的。
并且堆也是垃圾回收的主要区域,当分配的对象生命周期结束,那么就需要通过垃圾回收器来将这个对象进行回收,避免一直占用堆中的内存。
若对象一直不回收,堆的可用容量达到的阈值,那么就会抛出OutOfMemoryError异常。告诉开发者堆中的内存已经使用完,没有多余的内存可以给对象分配了,同时也无法再扩展了。
方法区也是一块内存共享的区域,该区域中主要存放的数据为被虚拟机加载的类信息、常量、静态变量。平时我们创建的类、或者类中设定的常量或者静态变量,这些数据都是存放在方法区中的。
并且方法区也是需要垃圾回收器回收的第二块区域,垃圾回收器也会回收掉不用的常量或者静态变量这些数据。
同时方法区也会有设定的阈值,当方法区内存已满,并且不可再扩展时,也会抛出OutOfMemoryError的异常。
运行时常量池是方法区的一部分,该区域主要用于存放编译器生成的各种字面量以及符号的引用。编译器和运行期时都可以将常量放入常量池中,并且常量池的内存是有限的,所以使用完并且无法扩展时将会抛出OutOfMemoryError的异常。
虚拟机栈是一块线程私有的内存区域,在该区域中主要存放的数据为局部变量表(方法参数以及方法内部定义的局部变量)、操作栈、方法返回地址等。
每个执行的方法都会对应一个栈帧,这个帧栈中就存放着执行方法的所有数据,例如参数、方法中的局部变量、返回地址等信息,也就是一个执行的方法都会在虚拟机栈中生成一个帧栈。
该区域对应的是正在执行的方法,因此当线程请求的栈深度大于虚拟机所允许的深度,那么就会抛出StackOverflowError的异常。
同时虚拟机栈也是有一定的阈值的,无法动态扩展,那么当内存已经满时,并且无法扩展时就会抛出OutOfMemoryError的异常。
本地方法栈也是一块线程私有的内存区域,该区域中主要存放的数据为虚拟机用到的native方法服务,例如我们所使用的UnSafe类,这个类中有compareAndSwap方法,这个方法就是native方法,这些native方法服务就是存放在本地方法栈中。
该区域中也会像虚拟机栈一样抛出StackOverflowError和OutOfMemoryError异常。
程序计数器也是线程私有的,并且从字面意思可以理解到该区域主要功能为计数,它也的确是一个起到一个计数的功能,程序计数器主要记录的是线程执行的字节码的行号,也就是可以记录代码执行到哪一行。
该区域中不会抛出异常信息。
在Java语言中创建对象一般通过一个new关键字来实现,例如Object obj = new Object();
当虚拟机收到一个new指令时,首先会检查这个指令的参数是否能在常量池中定位到一个类的符号引用(这里就是obj这个参数),然后检查这个符号引用代表的类是否已经被加载、解析、初始化过,如果没有则执行相应的加载过程。
当虚拟机进行加载对象时,首先会为新生对象分配内存,对象所需要内存的大小在类加载完成之后就可以确定。
当内存分配完后,虚拟机需要将分配到的内存空间都初始化为零值,也就是对象中的字段如果为整型,则初始化为0,如果为字符串型,则初始化为null等
接下来就要对对象进行必要的设置,比如对象是哪个类的实例,如何找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。以上这些信息都是存放在对象的对象头中的。
从对象创建时知道了的布局中存在对象头,那么还存在其它信息。
对象的内存布局分为三部分:对象头、实例数据、对齐填充
对象头包括两部分信息:一部分存储对象自身的运行时数据,比如哈希码、GC分代年龄、锁状态、线程持有锁等信息;另一部分是类型指针,也就是对象指向它的类元数据的指针(标识这个对象属于哪个类).
实例数据:实例数据中存储的就是对象的有效信息,也就是在代码中定义的各种类型的字段内容,比如如果对象中定义了一个int i = 1;那么1这个数据就会保存在实例数据这部分。
对齐填充:对齐填充并不是必然存在的,它仅仅是起着占位符的作用,因为HotspotVM的自动内存管理系统要求对象的起始地址必须是8的整数倍(也就是对象的大小必须是8字节的整数倍),对象头部分正好是8的整数倍(因为对象头在32位或者64位机器上占位符分别是32和64),所以实例数据部分没有对齐时,就需要对齐填充来补全。
在前面已经说过了Java虚拟机的内存区域,在栈中会随着方法的开始和结束进行入栈和出栈,随着方法的结束或者线程的结束内存自然就回收了。但是Java堆和方法区不一样,这部分的数据是一个动态的且共享的,我们只有在运行期间才知道会创建哪些对象,所以内存分配和回收也是动态的,这就需要一个方法来对堆和方法区中的内存进行回收。
在进行垃圾收集时,要考虑的就是哪些内存需要回收?什么时候回收?怎么回收?
首先就是要判断在JVM中哪些对象已经死去,哪些对象仍然活着,死去的可以进行回收(不会再被任何途径使用的对象)
这就需要JVM中的两个算法来判断
引用计数法的算法是这样实现的:给对象添加一个引用计数器,每当有一个地方引用这个对象时,计数器就加1 ,引用失效时计数器就减1,当计数器为0时则表明这个对象已经没有被其它地方使用了。
这个算法的思想为:在虚拟机中将GC Roots的对象作为起点,从这些起点开始向下搜索,搜索的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象不可用。
可以作为GC Roots对象的有:
public class Test {
//方法区中类静态属性引用的对象,方法区中会存在一个hello,该hello也会指向堆中的Hello对象,也就是需要引用Hello对象
private static Object hello = new Object ();
//方法区中常量引用对象,常量池中会存在一个getHello,getHello会指向堆中的Hello对象,也就是需要引用Hello对象
private static final Object getStr = new Object();
//虚拟机栈中的引用对象 public static void main(String[] args) {
Object obj = new Object(); }
}
对于本地方法栈暂时没想到如何进行引用的,因为本地方法栈也就是native方法来实现的,暂时我所了解的只有Unsafe类是一个本地类,里面有compareAndSwap的本地方法,但是没想到如何实现这个GC Roots。
标记-清除算法分为标记、清除两个阶段,在标记阶段, 会标记出所有需要回收的对象,在标记完成之后,再进行统一的回收。
这种算法存在一定的问题:一个就是效率问题,因为标记、清除两个过程的效率都不高。另一个问题就是空间问题,标记清除之后会产生大量的不连续的内存碎片,如果程序运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不触发一次垃圾收集。
在复制算法中,实现思想为:将内存按照容量划分为大小相等的两块,每次只使用其中一块,当一块内存用完了,就将或者的内存复制到另一块上,然后将已经使用过的内存空间都清理掉。这样的实现每次只会对半区进行回收。所以这种方法会导致可用内存缩小一半。
现在这种算法主要用在回收新生代中,在新生代中会将内存分为Eden区和From Survivor区和To Survivor区,回收时将Eden区和From Survivor区还活着的对象复制到To Survivor区中,清理掉Eden区和From Survivor。然后From Survivor变为To Survivor,To Survivor变为From Survivor.
复制算法中当对象的存活率都比较高时,就需要进行多次的复制操作 ,这样效率就会变低,这在老年代中是影响效率的,所以在老年代中提供了标记-整理算法
标记整理算法思想为:标记过程仍然跟标记-清除算法一样,但是后续并不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
这种算法可以理解为,当我们使用可达性分析法时,在标记整理的标记阶段,会将活着的对象进行标记,我们就知道了,哪些对象还存活,那这个时候就将这个或者的对象向一端移动,然后清理掉端边界以外的内存。这样就减少产生内存碎片的问题,也避免了复制算法的内存使用问题。但是这个算法也会存在一些弊端,就是效率的问题,在标记-整理算法中不仅要标记存活的对象,还要整理所有存活对象的引用地址。
分代收集算法是将Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用合适的收集算法。在新生代中,每次垃圾收集时都会有大批对象死去,只有少量存活,这时候就选用复制算法,因为这是只要堆少量的存活对象进行复制就可以完成收集。而老年代中,对象存货率高、没有额外的空间进行分配担保,所以这时候就可以选用标记-清理或者标记-整理算法。
Serial收集器:单线程收集器,进行垃圾回收时需要暂停其他所有工作的线程,直到收集结束。
ParNew收集器:Serial收集器的多线程版本,可以保证垃圾收集线程与工作线程同步执行
Parallel Scavenge收集器:使用复制算法收集器,同时也是一个并行的多线程收集器。
Serial Old收集器:该收集器的目标是获取最短回收停顿时间,减少回收时系统停顿的时间,也是使用标记-清除算法实现的。
CMS收集器:Serial Old是Serial收集器的老年代版本,同样也是一个单线程收集器,使用标记-整理算法
G1收集器:该收集器有如下几个特点:分代收集、并发与并行、空间整合、可停顿预测。
对象的内存分配,从大方向来说就是在堆上分配,将对象分为新生代和老年代来进行划分的话:
首先通过jps来查看当前虚拟机运行的程序
然后通过jstack -F vmid 查看线程堆栈
jstack -m vmid 查看本地方法,显示C/C++的堆栈
jstack -l vmid 查看关于锁的附加信息
打开JConsole,选择当前正在进行的进程,查看该项目中堆、线程、类、CPU占用率等信息。
从内存中可以看到Eden区、Old区、Survivor区等信息。
从线程中可以查看目前项目中运行了多少线程以及检测是否存在死锁的问题。
从VM概要中看到虚拟机的类型、当前堆大小、以及垃圾收集器等信息。
打开VisualVM,选择一个正在运行的项目,可以查看线程、内存等信息的占用量。另外在这个工具中支持生成dump文件,复制到桌面中。
然后导入dump文件。
虚拟机在对对象进行内存分配前,首先就是要将这个对象类加载到虚拟机中,所以这就需要虚拟机将Class文件加载到内存中,然后对数据进行校验、转换解析和初始化,最终才会形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
类被虚拟机加载到内存到清理出内存这个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载七个阶段。可以将验证、准备、解析统称为连接。
上面说完了类在虚拟机中加载的过程,那么在虚拟机中就需要有一个机器做加载类的这个过程。这就通过虚拟机中的类加载器来实现的。
类加载器是不仅仅做加载类的工作,它还需要跟加载的类来确立类在Java虚拟机中的唯一性。可以理解为同一个Class文件被不同的类加载器加载,那么这生成的两个类也是不相等的。所以比较两个类是否相等要以被同一个类加载器加载为前提。
在虚拟机中提供了三种类加载器:
双亲委派模型思想为:除了启动类加载器之外,其它类加载器都应该有自己的父类加载器。这样如果一个类加载器收到了类加载的请求,那么它不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器无法完成加载请求时(也就是它的路径范围内没有找到这个类),那么子加载器才会尝试自己去加载。
这样实现的话可以保证最终一个Class文件只会被一个加载器加载然后生成一个类,而不会导致系统中出现多个不同的类。
最后小白开通的公众号求关注,哈哈哈哈哈哈,微信搜索:【陈汤姆的文化产业基地】