最近在看JVM的文章、书籍和相关视频,站在巨人的肩膀上,简单记录一下个人理解,这是我在的第一篇文章,如理解有误或有出入请联系我更改。
一、什么是JVM?
JVM全程Java Virtual Machine,普遍的认识是JVM是java虚拟机。当然,这个理解并没有错,更完善的解释是:JVM是一种技术规范,是设计java虚拟机的规范,并且基于这个规范不同的公司可以有不同的实现,只是我们一般的开发者用的都是Sun公司实现的JRE的JVM,另外,IBM,BEA等公司都有自己的JVM实现。
二、JVM作用和特性
Java虚拟机有个编译器,它负责将java源代码编译成.class文件;Java虚拟机中有个Java解释器,它负责将字节码(.class)文件解释成为特定的机器码进行运行,因此Java源程序需要通过编译器编译成为.class文件,而Java虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统(类同一个计算机)。有了这些基础,使得Java语言有跨平台运行这个最大的特性,也就是传说中的一次编译到处运行。
三、Java代码编译和执行流程
1.java代码编译成.class机制:
将java代码编译成.class文件主要是通过javac来完成的,这个编译过程主要包括:词法分析和输入到符号表;注解处理;语义分析和生成字节码;
详细的流程:源代码文件*.java -> 词法分析器 -> tokens流 -> 语法分析器 -> 语法树/抽象语法树 -> 语义分析器 -> 注解抽象语法树 -> 字节码生成器 -> JVM字节码文件*.class
我们简单对上图进行一下解释说明:
A.语法分析器读取源代码,一个字节一个字节的读进来,找出这些词法中我们定义的语言关键词如:if、else、while等,识别哪些if是合法的哪些是不合法的。这个过程的结果就是从源代码中找出了一些规范化的token流,就像人类语言中,给你一句话你要分辨出哪些是一个词语,哪些是标点符号,哪些是动词,哪些是名词。
B.语法分析器再次对上一步识别出的Token流进行分析识别,这一步就是检查这些关键词组合在一起是不是符合java语言规范。如if的后面是不是紧跟着一个布尔型判断表达式。这个过程的结果就是形成一个符合Java语言规定的抽象语法树,抽象语法树是一个结构化的语法表达形式,它的作用是把语言的主要词法用一个结构化的形式组织在一起。这棵语法树可以被后面按照新的规则再重新组织。
C.语义分析器把一些难懂的,复杂的语法转化成更简单的语法。就如难懂的文言文转化为大家都懂的百话文,或者是注释一下一些不懂的成语。这个过程的结果就是将复杂的语法转化为简单的语法,对应到Java就是将foreach转化为for循环,还有一些注释等。最后生成一棵抽象的语法树,这棵语法树也就更接近目标语言的语法规则。
D.字节码生成器将会根据经过注释的抽象语法树生成字节码,也就是将一个数据结构转化为另外一个数据结构。就像将所有的中文词语翻译成英文单词后按照英文语法组装文英文语句。这个过程的结果就是生成符合java虚拟机规范的字节码。
以上只是理论上的大体编译流程,里面包含很多具体的细节知识,可以参考《编译原理》。
另外,我们可以通过javap -c .class文件查看到.class文件的字节码信息,主要包括:
(1) 结构信息:class文件相关信息。
(2) 元数据:Java源码中的声明和常量信息。
(3) 方法信息:Java源码语句和表达式对应的字节码。
2.jvm类加载机制:
着重说一下双亲委派机制。
什么是双亲委派机制?
如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派机制。
双亲委派机制有什么优点(意义)?
试想一下,如果没有双亲委派机制,我们在Class A里加载一个java.lang.System,在Class B里加载一个java.lang.System,这样就可能会有2套System的字节码,所以双亲委派机制的意义就是防止内存中出现多份同样的字节码。但是如果有双亲委派机制,Class A和B都会找父ClassLoader,一旦A加载过了,其ClassLoader或者其父ClassLoader就会有System类的缓存,B再加载时就会拿到这个System,这样就不会出现多套字节码了。
再试想一下,你能不能写一个类叫java.lang.System?答案是,写可以,但是默认的情况下你写的这个System是不会被加载到jvm的,因为java的System类是Bootstrap加载器加载的,在启动JVM时已经加载进去了,在双亲委派机制下,你这个类是加载不进去的。
在我们无法改变双亲委派机制的前提下,如果我就想让JVM可以加载到我的这个System类,该如何做呢?这就需要我们打破双亲委派机制。这就要求我们一是要继承ClassLoader类,二是要重写loadClass和findClass方法。我们知道loadClass是加载一个类,这个过程默认是走双亲委派机制的,所以我们在重写loadClass方法时,可以先尝试交给systemClassLoader进行加载,如果找不到就交给自己的ClassLoader处理,而不是交给其父ClassLoader处理,这样就可以打破双亲委托机制。
再想一下,正常情况为什么有人想着去打破双亲委托机制呢?答案是,打破这个机制后是另外一片自由的天地:代码热替换(HotSwap)、模块热部署(HotDeployment),android的热修复、插件化等!
最后我们来看一张我从网上copy下来的,关于双亲委派机制加载Class的图:
关于loadClass和findClass这些方法以及更多详细的类加载机制问题可以查看:https://blog.csdn.net/javazejian/article/details/73413292
3.jvm类执行机制:
jvm类执行是又执行引擎来完成的,执行的是字节码文件,执行是通过各个指令来完成的,也就是字节码文件在jvm的解释下形成一条条可执行的指令,这些治疗操作内存分配区(包括栈、堆、方法区等等)分配的相关数据。执行的流程图如下:
四、JVM的内存区域划分
一般的情况下我们大体把JVM内存划分为三个部分:类装载器子系统,执行引擎,和运行时数据区。这三个部分的内存分配主要体现在上述的java代码加载和执行的流程里,其中我们主要来分析一下运行时数据区,因为这一部分是内存分配、管理和回收的核心。OK,我先上一张从网上copy下来的图。
运行时数据区内存分配主要包括:方法区,堆,虚拟机栈,本地方法栈,程序计数寄存器。其中方法区和堆是所有线程共享的,这里占用的内存等待GC回收;而虚拟机栈,本地方法栈和程序计数寄存器是线程私有的,这里占用的内存在线程运行完会自动释放(其中栈的使用是非常明显的体现)。
1、方法区:
存放了要加载的类信息(名称、修饰符等)、静态变量、final类型的常量、类中的Field信息、和方法信息。当开发人员在程序中通过Class对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区域,同时方法区域也是全局共享的,在一定的条件下它也会被GC,当方法区域需要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。JVM用持久代(Permanet Generation,1.8后改成元空间,可直接使用系统内存)来存放方法区,可通过-XX:PermSize和-XX:MaxPermSize来指定最小值和最大值。
2、堆:
用来存储对象实例以及数组值的区域。可以认为所有通过new创建的对象的内存都在堆中分配,该部分内存是由自动管理内存系统(Automatic Storage Management System,也即是常说的 “Garbage Collector(垃圾回收器)”)所管理,所以这一块是java里内存管理和回收的核心区,也是开发者所要面临的重灾区。JVM将该区域划分为新生代(New Generation)和旧生代(Old Generation),其大小可以通过-Xmx和-Xms来控制。
3、虚拟机栈:
java 虚拟机栈是线程私有的。每一个 JVM 线程都有自己的 java 虚拟机栈,这个栈与线程同时创建,它的生命周期与线程相同,上文中也提到随着线程的结束该栈内存也会被释放。每个虚拟机栈对应一个线程,一个线程包括1个或者多个方法,在虚拟机栈中,每个方法都是一个栈帧(Stack Frame),栈是一个数据结构,它的特点是FILO,所以方法在执行的过程也就是进栈和出栈的过程,当方法return或者发生异常时,也就意味着该栈帧出栈并释放内存。OK,我们再来简单描述一下栈帧,栈帧的主要组成:局部变量区(包括方法参数和局部变量,对于instance方法,还要首先保存this类型,其中方法参数按照声明顺序严格放置,局部变量可以任意放置),操作数栈,帧数据区(用来帮助支持常量池的解析,正常方法返回和异常处理)。
4、本地方法栈:
保存native方法进入区域的地址。其原理与虚拟机栈类似,虚拟机栈执行的是java字节码的方法,而本地方法栈执行的是native方法。
5、程序计数寄存器:
每个线程均有,占用内存较小,可以理解为当前线程的行号标志器,其保存的内容总是指向下一条将被执行指令的地址。java的多线程离不开这个程序计数寄存器,当线程切换时,由于程序计数寄存器的存在,会让线程恢复到正确的执行位置。
五、垃圾回收机制
这个是重灾区,也是开发人员开发或者面试过程必须要面临的问题。
什么是垃圾回收机制?
垃圾回收是将内存中不再被使用的对象进行回收,是由垃圾收集器Garbage Collection GC来实现的,具体地说,其实就是一个守护线程的一个方法。说到垃圾回收,我们就不得不说以下几个方面:
1、java对象的引用类型。
不同的对象引用类型, GC会采用不同的方法进行回收,JVM对象的引用分为了四种类型:
(1)强引用:默认情况下,对象采用的均为强引用(这个对象的实例没有其他对象引用,GC时才会被回收)
(2)软引用:软引用是Java中提供的一种比较适合于缓存场景的应用(只有在内存不够用的情况下才会被GC)
(3)弱引用:在GC时一定会被GC回收
(4)虚引用:由于虚引用只是用来得知对象是否被GC
2、关于“代”对GC的影响。
上文中其实已经说了关于java内存关于代的分配了,这里再稍微详细阐述一下:
(1) 新生代(1/3堆内存):
新生代分3个区,一个Eden区,两个Survivor区(一个是From Survivor,一个是To Survivor,是2个交换区),这三者的内存分配占比是8:1:1。Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间,这块空间又称TLAB,TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。
对象刚分配的时候,大部分都在新生代,而这个大部分对象中的大部分在Eden区中生成(特殊案例:我们JVM的堆内存一般分配后是固定的(假设100M),所以Eden区(假设80M)和2个Survivor区(各10M)的内存分配也是固定,假设当你new了一个特别大对象(90M),这个时候新生代是存放不下这个对象的,只能存到旧生代里去了),新生代的GC叫Minor GC,新生代的GC相对而言是非常频繁的。
当GC发生时,Eden中没有被回收到对象的age+1,同时会放到Survivor1区中区;如果再次发生GC,发现Survivor1区中的对象还没有被回收,其age+1,同时该对象会被挪到Survivor2区;如果第三次再GC,发现该对象依然没有被回收,其age+1,同时该对象会从Survivor2区挪到Survivor1区,就这样反反复复的GC后,该对象被挪过来挪过去(这就是交换区的作用),当age+到15还没有被回收时会被移动到旧生代。当然,这个age=15也不是必然的,有一些特殊情况可能没有达到15也会移动到旧生代。
一个对象在2个Survivor区反复挪来挪去过程使用的算法是复制回收算法,也就是把Survivor1还存在的对象复制到Survivor2,把Survivor1的对象干掉,或者把Survivor2还存在的对象复制到Survivor1,然后把Survivor2的对象干掉,这样同时也保证肯定有一个Survivor区域是空的。
注意:JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,理论上总是有一块Survivor区域是空闲着的。因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。
(2) 旧生代(2/3堆内存):
旧生代的GC叫Full GC。旧生代的对象相对于新生代里的对象来说是比较稳定的,其GC的频率也低很多,因此采用标记清除算法+标记整理算法一起来进行回收,所谓标记就是扫描出存活的对象,然后再进行回收未被标记的对象,回收后通过标记整理算法对用空出的空间要么进行合并,要么标记出来便于下次进行分配。
旧生代的GC是耗时的,会导致JVM短暂暂停,暂停会导致程序出现卡顿,所以优化内存时这是一个方向点。
如果旧生代的内存不足以存储新进来的对象时,就会报OOM。
(3) 永久代:
一般情况不会回收,但是也是有回收的可能性,只是触发率比较低,需要满足比较严苛的条件(具体没研究过),而且1.8后永久代已经被元空间取代,元空间已经不再使用JVM管理的内存了,用的是直接内存,也就是直接使用你服务器的内存或者电脑内存,所以这一部分的回收我们基本可以放弃了。
3、回收算法:
(1) 复制算法
IBM研究表明新生代中的对象98%是朝生夕死,所以新生代才有了3个分区和比例8:1:1的划分,Eden区对象死的很快,分配多一些内存区域用于对象的快速创业,然后死亡。同时复制Eden+其中的一个Surviver区中存活的对象到另外一个Surviver区,最后清理掉Eden和刚才用过的Survivor的空间,这样就保证每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”。
(2) 标记清除算法
标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。
在标记阶段首先通过根节点,标记所有从根节点开始的对象,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。标记清除算法带来的一个问题是会存在大量的空间碎片,因为回收后的空间是不连续的,这样给大对象分配内存的时候可能会提前触发full gc。
(3) 标记整理算法(也叫标记压缩算法)
标记-压缩算法是一种老年代的回收算法,它在标记-清除算法的基础上做了一些优化。首先也需要从根节点开始对所有可达对象做一次标记,但之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
(4) 增量算法
增量算法的基本思想是,如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
4、垃圾回收器
(1) Serial收集器
(2) ParNew收集器
(3) Parallel Scavenge收集器
(4) CMS收集器
(5) G1收集器
(6) ZGC收集器(有色指针、加载屏障)
六、与GC相关的常用参数
-Xmx: 设置堆内存的最大值。
-Xms: 设置堆内存的初始值。
-Xmn: 设置新生代的大小。
-Xss: 设置栈的大小。
-PretenureSizeThreshold: 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配。
-MaxTenuringThrehold: 晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC之后,年龄就会加1,当超过这个参数值时就进入老年代。
-UseAdaptiveSizePolicy: 在这种模式下,新生代的大小、eden 和 survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。在手工调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量 (GCTimeRatio) 和停顿时间 (MaxGCPauseMills),让虚拟机自己完成调优工作。
-SurvivorRattio: 新生代Eden区域与Survivor区域的容量比值,默认为8,代表Eden: Suvivor= 8: 1。
-XX:ParallelGCThreads:设置用于垃圾回收的线程数。通常情况下可以和 CPU 数量相等。但在 CPU 数量比较多的情况下,设置相对较小的数值也是合理的。
-XX:MaxGCPauseMills:设置最大垃圾收集停顿时间。它的值是一个大于 0 的整数。收集器在工作时,会调整 Java 堆大小或者其他一些参数,尽可能地把停顿时间控制在 MaxGCPauseMills 以内。
-XX:GCTimeRatio:设置吞吐量大小,它的值是一个 0-100 之间的整数。假设 GCTimeRatio 的值为 n,那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集。
最后感谢:
https://www.cnblogs.com/lishun1005/p/6019678.html
https://blog.csdn.net/qq_36394738/article/details/80366579
http://www.importnew.com/26383.html
https://my.oschina.net/u/3346994/blog/866168
https://segmentfault.com/a/1190000002579346
https://juejin.im/post/5af1b485f265da0ba266f433
https://blog.csdn.net/javazejian/article/details/73413292