深入Java虚拟机(JVM)
一.Java 整体的运行结构以及与 JVM 的关系
ClassLoader的在关键是告诉你,我要进行类加载,而在进行类加载的时候,就一定有一个正常的环境属性CLASSPATH,没有CLASSPATH就无法找到我们的目录所在,这样程序才会跑到JVM中解释运行。
而JVM到底有什么组成结构呢?首先有一个执行引擎,它需要一个本地方法接口,通过本地方法接口调用本地一些资源,比如多线程、磁盘、内存分配等等。本地方法说的再简单一点就是JNI,这个JNI在整个过程里面,它要去调用一个所谓的本地方法库。本地方法库是什么?是由各个操作系统自已定义的一些内容,比如我们现在做一个简单举例,对于JDK我们是要分很多系统版本的,为什么要分?是因为我们不同的系统对于某一项操作的指令提供的底层的函数,含义是不同的。而真正在整个过程中起决定性环境是哪个呢?有一个所谓的运行时区,这里面就是一个所谓的运行时的数据区,所以在这样的一个整体的设计环境里面,这就是JVM在整个系统开发之中最为直白的一个操作组成形式。
其实对我们真正程序而言,在整个JVM研究时,关键部分应该放在运行时数据区。运行时数据区设置合理了,整个JAVA程序也就能够正常完成了。
1.类加载器在 JDK 1.8 以前和 JDK 1.9 以后
不管版本如何变化,双亲加载依然是使用的主体,不可能改变。
package com.bijian.study; public class TestClassLoaderDemo { public static void main(String[] args) { String str = ""; System.out.println(str.getClass().getClassLoader()); // Bootstrap加载器 } }
运行结果:null
因为这里是Bootstrap加载器。
package com.bijian.study; class Member { } public class TestClassLoaderDemo { public static void main(String[] args) { Member member = new Member(); System.out.println(member.getClass().getClassLoader()); // Bootstrap 加载器 System.out.println(member.getClass().getClassLoader().getParent());// Bootstrap加载器 System.out.println(member.getClass().getClassLoader().getParent().getParent()); } }
JDK1.8及以下版本运行结果:
sun.misc.Launcher$AppClassLoader@73d16e93 sun.misc.Launcher$ExtClassLoader@15db9742 null
JDK1.9及以上版本运行结果:
jdk.internal.loader.ClassLoaders$AppClassLoader@4b85612c jdk.internal.loader.ClassLoaders$PlatformClassLoader@66133adc(改变:ExtClassLoader) null
其中,null就是bootstrap 加载器,可以看到JDK1.9的类加载器已经发生了改变。
2.运行时数据区是整个JVM设计的关键所在,那么在整个运行时数据区里面,就有若干个组成部分
方法区:
栈内存:是程序的运行单位,里面存储的信息都使与当前线程有关的内容,包括:局部变量、程序的运行状态、方法返回值等。
堆内存:Java 的引用传递的实现依靠的就是堆内存,同一块堆内存空间可以被不同的栈内存所指向。
程序计数器:是一个非常小的内存空间,这个空间主要是进行一个计数的操作,对象的晋升问题(依靠的就是计数器)。
本地方法栈内存:在进行递归调用的时候所保存的栈帧的内容,栈帧的组成部分有:局部变量表、操作数栈、当前方法所属于类的运行时产量的引用、返回地址。
在整个 JVM 运行时数据区之中,关键的部分在于需要进行堆的优化。既然要进行优化,那么就必须清楚 Java 的对象访问模式。Java在进行对象引用的时候并没有使用到句柄的概念(步骤多一些,导致性能下降),它直接采用的HotSpot虚拟机标准的指针引用。
Java 是一个开源的编程语言,实际上在世界的技术公司里面有三个所谓的虚拟机标准:
a.SUN(被 Oracle 收购了):所推出的 JVM 是基于HotSpot标准的虚拟机;
b.BEA(被 Oracle 收购了):JRockit;
c.IBM(曾经打算收购 SUN 公司):JVM's、J9。
Oracle不可能花费额外费用去维护两个虚拟机标准,所以未来的发展趋势:HotSpot + JRockit,而现在所使用的 JVM 实际上也全部都是 HotSpot 标准,执行:java -version
D:\>java -version java version "1.8.0_131" Java(TM) SE Runtime Environment (build 1.8.0_131-b11) Java HotSpot(TM) 64-Bit Server VM (build 25.131-b11, mixed mode)
现在用的是HotSpot虚拟机,且用的是混合模式,混合模式就是在当前的程序当中,我可以进行编译,还可以进行程序的执行。如果只想进行纯编译,则执行:java -Xcomp -version
D:\>java -Xcomp -version java version "1.8.0_131" Java(TM) SE Runtime Environment (build 1.8.0_131-b11) Java HotSpot(TM) 64-Bit Server VM (build 25.131-b11, compiled mode)
一般都不需要去改变所谓的 JVM 运行模式,但是有一点需要清楚,当前的Java 行业里面,Java已经不再适合于进行桌面程序开发了,也就是说客户端程序不是Java的重点了,那么这样一来对于资源的启动分配就非常重要了。
客户端程序要求启动快,中间运行时可以稍微慢一点,而服务端程序启动可以慢一点,但中间运行时要快。所以默认的JDK的配置使用的全部是服务器的运行模式,我们找到jdk的安装目录,在D:\software\Java\jdk1.8.0_131\jre\lib\amd64目录下,找到jvm.cfg文件。
# You may also select a JVM in an arbitrary location with the # "-XXaltjvm=<jvm_dir>" option, but that too is unsupported # and may not be available in a future release. # -server KNOWN -client IGNORE
这就是两个运行的模式,server是已知的模式,client是忽略的模式。
如果想让我的jvm变成客户端模式?可以变,但是一般没有人做这样做。在整个设计里面,不止是通过它来控制,这里面是有一个相应的操作目录存在的,也就是说它在整个流程里面需要提供一个对应的服务端的配置,这样才保证HotSpot得到客户端的启动能力,这是一个相当麻烦的事情。这从一个正常心理学的来讲,没有必要这样去做的。如现在有人和你说编写一个桌面图形界面,非常非常好用,那这人生活的一个古老的年代,不太建议大家去改这个东西,去修改这个配置研究处理流程得不偿失。
二.堆内存组织结构以及与内存有关的调整参数
JVM 的组成只是做为一个概念存在,如果每一天都只是进行 JVM 结构研究对开发的作用很小,最关键的问题就是优化:堆内存空间。
package com.bijian.study; public class TestGCDemo { public static void main(String[] args) { String str = ""; for (int x = 0; x < Integer.MAX_VALUE; x++) { str += x + str; // 多么万恶 str.intern(); // 万恶加三级 } } }
对于str.intern()方法详见《Java String.intern的深入研究》。
堆内存之中需要考虑关于GC的问题,真正导致程序变慢的原因就在于堆内存的控制策略上。控制回收策略(JDK 1.9 之后的默认策略已经非常好了,因为其已经更换为了G1)
1.年轻代
a.伊甸园区:新生的小对象。每当使用关键字 new 的时候默认的时候都会在此空间内进行对象创建。
如果创建的对象过多,那么最终的结果也有可能造成伊甸园区的内存空间沾满,所以此时就会发生晋级的操作(若干次MinorGC执行还保留的对象,晋升到存活区)。
b.存活区:进行一些 GC 后保存的对象(程序计数器,会记录 GC 的执行次数),存活区准备两块空间:S0、S1,有一块空间永远都是空的,是向老年代晋升。
2.老年代
哪些又臭又硬的对象,这些对象都已经经历了无数次的 GC 之后依然被保留下来的对象。于是这些对象很难被清除。但是有可能也会被清除。同时如果是一个很大的对象,那么默认的也会直接保存到老年代,如果现在老年代空间不足了,会出现 MajorGC(FullGC),进行老年代的清理(这样的清理是非常耗费性能的),所以这也是为什么不去使用 System.gc()方法。
3.于是现在最核心的问题在于:如何可以进行堆的结构优化
a.每一块的空间实际上都会提供有一个伸缩区;
b.伸缩区的考虑是在某个内存空间不足时:会自动打开伸缩区继续扩大可用的内存,当发现当前的区域的空间内存可以满足要求的时候,就可以进行收缩。
c.如果不进行收缩的优点:可以提升堆内存分配效率;
d.如果不进行收缩的缺点:空间太大了,那么如果没有选择好合适的GC算法,就会造成堆内存的性能下降。[类比:你现在住在一个大概有500平米的房间里面,你很勤劳,每次都进行打扫,这里你会发现,每次都打扫500平的房子,时间是非常长的,于是可以想有些空间我经常不去,我就不用经常性的去打扫,可以只去看看,如果不脏就不打扫了]
package com.bijian.study; public class ShowSpaceDemo { public static void main(String[] args) { Runtime runtime = Runtime.getRuntime(); // 获取 Runtime 实例化对象 System.out.println("MAX_MEMBER:" + (runtime.maxMemory()/1024/1024)); // 最大可用内存 System.out.println("TOTAL_MEMBER:" + (runtime.totalMemory()/1024/1024)); // 默认的可用内存 } }运行结果:
MAX_MEMBER:1753 TOTAL_MEMBER:119maxMemory:默认大小为当前物理内存的1/4。这里是1753M,1753*4=7012M,我电脑共有8G内存(7.7G可用),符合当前物理内存的1/4的。
totalMemory:默认大小为当前物理内存的1/64,这里是119M,119*64=7616M,也就是说有还有63/64的空间实际上在给我们的伸缩区做准备的。
伸缩区有这么大的处理范围,所以在进行堆内存分配的过程里面当用户访问量增加的时候就一定会导致不断的判断空间是否充足,不断的进行内存空间的增长,不断的进行内存空间的收缩与释放。[JVM为什么这样做?就告诉你如果你的内存不是很大的时候,我们的GC会比较快]
至关重要的两个参数:可以使用的单位(K、M、G)
-Xms:设置初始化的内存分配大小;
-Xmx:设置最大的可用内存空间。
-Xms16g -Xmx16g
运行后:
MAX_MEMBER:6869 TOTAL_MEMBER:6869
这样两个内存大小就一样了,内存空间也变成我们设置的比较大了。在这样的空间里面,在这种方案里面,可以减少堆内存的收缩处理操作。当然,这是一个整体性的调优过程,再往下深入的话,整个流程里面我们要知道,整个堆内存还会考虑到年轻代和老年代,我们现在这两个参数调整是针对整个堆内存,而不是针对某个老年代或某个年轻代。实际上也不太建议大家去调整年轻代和老年代的关系比例。
e.当堆内存空间很大得情况下就需要考虑到GC的执行效率问题,所以在这个环节里就需要考虑两个技术名词:BTP、TLAB。
年轻代:
所以在这个环节里面就需要考虑两个技术名词:BTP、TLAB
· BTP:在伊甸园区采用栈的形式将最晚创建的对象保存在栈顶。
· TLAB:分块保存,适合于多线程的处理操作上。
-Xmn:设置年轻代的空间大小,默认采用的是物理内存的1/64
-Xss:设置每一个线程所占用的栈的大小[大约3000多个,一般都完全够用,这块不用太关注和调整]
-X:SurvivorRatio:设置伊甸园区与两个存活区之间的内存分配比,默认8:1:1。
老年代:
与年轻代比率:-XX:NewRatio
当对象很大的时候往往不在年轻代进行保存,而是直接晋级到老年代,可利用“-XX:PretenureSizeThreshold”设置。
【分水岭】
JDK1.8 之后取消了所谓的永久代,而变为了元空间(不在堆内存里面保存,而是直接利用物理内存保存。)
三.GC 算法(主流:G1、未来:ZGC)
GC算法的选择直接决定了你最终程序的执行性能。
传统意义上进行的回收处理操作,只是认为简单的有垃圾产生了,而后自动进行GC操作(MinorGC、MajorGC),或者手工利用“System.gc()”操作(MajorGC、FullGC)。
Java的GC机制是经历快了20年的发展,对于电脑硬件技术也已经产生了很大的变化,最初的时候是在一块CPU上进行多线程的分配,而现在手机都多核CPU,多线程支持了。
对于GC算法里面就需要考虑不同的内存分代(新的JDK开发版本之中,以及现在项目里面不建议再使用如下的GC算法):
a.年轻代 GC 策略:串行 GC、并行 GC;
b.老年代 GC 策略:串行 GC、并行 GC。
1.年轻代串行GC
a.扫描年轻代中的所有存活对象;
b.使用 MinorGC 进行垃圾回收,同时将还能够存活下来的对象保存在存活区(S0、S1)里面;
c.每一次进行 MinorGC 的时候都会引起S0和S1的交换;
d.经过若干次 MinorGC 还能够继续保存下来的就进入到老年代。
2.年轻代并行GC
算法:复制-清理算法,在扫描和复制的时候均采用多线程的处理模式来完成。
在年轻代进行MinorGC的时候实际上也由可能触发到老年代GC操作。
3.老年代串行GC
算法:标记-清除-压缩;
a.扫描老年代中的存活对象 ,并且进行对象的标记;
b.遍历整个老年代的内存空间,回收所有标记对象;
c.为了保证可以方便的计算出老年代的大小,还需要进行压缩(碎片整理,把空间集中在一起。)
4.老年代并行GC
在最早的时候主要使用了此种 GC 算法,但是这种算法会有一个严重性的问题:STW(产生中断,因为需要进行垃圾的标记)。
a.暂停当前的所有执行程序(挂起);
b.标记出垃圾,标记的时间越长,那么挂起的时间就越长,如果此时你的堆内存空间很大,那么时间一定会更长;
c.预清除处理;
d.重新标记过程:看看还有没有垃圾;
e.进行垃圾的处理;
f.程序恢复执行。
5.实例
特别说明:如下实例在JDK11版本上演示,我的实例的JDK版本如下:
D:\develop\workspace\JVMStudy\bin>java -version java version "11.0.1" 2018-10-16 LTS Java(TM) SE Runtime Environment 18.9 (build 11.0.1+13-LTS) Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.1+13-LTS, mixed mode)
输出GC详细过程,以前使用的:-Xms48m -Xmx48m -XX:+PrintGCDetails,新版本需使用:-Xms48m -Xmx48m -Xlog:gc*
给上面的TestGCDemo.java实例运的的JVM设置48m内存,即:-Xms48m -Xmx48m,运行很快就能看到内存溢出,如下所示:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3332) at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124) at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448) at java.lang.StringBuilder.append(StringBuilder.java:136) at com.bijian.study.TestGCDemo.main(TestGCDemo.java:9)
但默认不会输出详细GC的过程,如果想要打印出详细GC过程,即JVM参数为:-Xms48m -Xmx48m -XX:+PrintGCDetails,运行提示:
调整JVM参数:-Xms48m -Xmx48m -Xlog:gc*,运行结果如下:
发现JVM用的是G1的GC算法,JDK11默认已不再支持我们所谓的并行、串行GC操作了,在这样的情况下,我们如果要回到原来的串行、并行算法操作,就要手工进行GC修改[当然,实际应用中不建议修改]。
a.串行GC:-Xms48m -Xmx48m -Xlog:gc* -XX:+UseSerialGC
串行操作的处理过程,年青代分配空间失败了,老年代、元空间的操作,再问老年代、再问年青代,总之它把你内存使用的频率、空余空间信息都会完整且详细的告诉你。
串行就相当于一个线程按串行的方式进行清理,清理的空间越大,所造成的内存的执行性能就越差。
b.并行 GC:-Xms48m -Xmx48m -Xlog:gc* -XX:+UseParallelGC
并行老年代、年青代的操作流程和串行GC是不一样的,这些详细的信息可以下来读。
c.并行年轻代 GC:-Xms48m -Xmx48m -Xlog:gc* -XX:+UseParallelNewGC
d.并行老年代 GC:-Xms48m -Xmx48m -Xlog:gc* -XX:+UseParallelOldGC
并行新生代不行了,但并行老年代的GC是可以执行的,详细过程可以按我上面的写法运行查看。
可是我们强调的是,不管你用串行、并行或其它各种操作,如并行年青代GC、并行老年代GC。最终的 GC 发展到了今天,已经不单单再以上的古老算法了,不管是并行还是串行算法,实际上都有可能引起大范围的程序暂停问题(程序的性能不高),现在最关键的问题就需要去解决大空间下的性能问题。
最初的电脑是没有这么高的硬件配置的,内存最早出现的时候售卖的单位是K,这样的背景下就产生了 G1回收算法(现在 JDK 1.9 之后的标配算法),G1就是准备N个内存空间,每个内存空间都有年青代和老生代,这样在操作过程中可能就其中某几块内存空间满了,于是我们就在这几个简单的空间上执行GC算法。这种G1最大的特点是支持的最大内存为 64G(每一个小的区域里面可以设置的范围1M-32M)
如果版本未升级,采用G1收集参数为:-Xms48m -Xmx48m -Xlog:gc* -XX:+UseG1GC
在这里使用了G1,而G1的收集流程里可以发现:使用两个workers、预准备、收集、提交操作,不同区域分块保存,这样就可以实现一个良好的设计结构了。
6.JVM 核心优化问题
a.减少伸缩区的使用;
b.提升GC的效率,G1是现在最好用的GC算法;
c.线程的栈的大小配置;
d.线程池的配置。
如果现在是在Tomcat下那么如何优化呢?找到apache-tomcat-8.0.50\bin下的catalina.sh,在这个文件中设置JAVA_OPTS="-Xms4096m -Xmx4096m -Xss1024K"这样一个配置,就可以改变整个当前使用的堆栈内存大小,我这里是JDK11的版本,用的是G1的收集器,如果还要改的话,再加上线程池、阻塞队列,再进行各种多线程的优化形式,才能最终形成优化方案。
解答:
1.JDK1.7的优化就只能是用并行的方法优化操作,JDK1.8可以用G1
2.UseConcMarkSweepGC和G1的区别及适用场景?UseConcMarkSweepGC适合于内存量小一点的情况,不要去搞一个很大的内存就行了,如果你的内存只有7、8个G,因为正常一个子业务系统就1G-2G就差不多了,这个就可以考虑用并行的GC完成。G1的主要特征在于它可以进行大内存的使用。
3.JVM参数哪里可以查到?官网
4.新生代的GC不会导致挂起,新生代的GC主要是在并行时操作,更适合1M-32M,都是可以的
5.4G物理内存可以用G1,建议大家把JDK更换为JDK1.8
6.cms在新生代里使用的弊端是什么?从整个垃极回收机制来讲,CMS只是在整过程当中引出的一个老年代的操作问题,在进行CMS操作的过程里面,实际上我个人觉得是在于它会引发老年代的操作,它在第一次和重新标记时才会暂停,这样对应用的影响是相对小的,但缺点是并发标记和回收的线程会与应用进行CPU和资源的争抢,可能会产生一些碎片
7.1.8的代码部署到服务器上怎么调置G1?如TOMCAT可以bin下的catalina.sh,进行JAVA_OPTS的配置,配置G1即可
8.在程序开发过程中就不用管GC算法了吗?在开发过程中少去生成一些无用的代码,就是性能上很大的提升,如无用的对象
9.Spring管理的bean,创建、回收是由Spring容器自已完成的,可以理解为在整个JVM中有一个很大的Spring对象,它在里面自已去管理各个子的bean的对象。Spring它也是由一个个JAVA的对象所组成的,但是它在整个组成的内部会由Spring内部去维护自已的一套容器管理对象,这是两个机制。而且Spring中没有这么多对象产生,它更多是单例设计的应用,它就避免了所谡的频繁的GC处理
10.jconsole:JVM连接操作,可以直接去查看的;jmap:主要用于打印指定Java进程(或核心文件、远程调试服务器)的共享对象内存映射或堆内存细节
11.java依然是不可被取代的商业的编程语言,至少没有能取代它做商业开发地位的语言,GO主要通讯那块,但它现在不太稳定,nodejs前端人居多
12.zgc据说可以在10毫秒内解决GC问题
13.当调整JVM无效时,还能怎么处理?说明主机性能已超负荷,多加几台主机,用集群就可以了
14.微服务不一定是Spring,但Spring搭建微服务是最方便的
特别说明:此文章是《开课吧》公开课的笔记。
上一篇: mysql中将长整型转换为时间类型或将时间类型转换为长整型
下一篇: 简谈Java的join()方法