JVM加载类的过程分为:加载、验证、准备、解析、初始化、使用、卸载等过程,在此过程之前,在我们java进程开始的时候,java进程会根据本地的dll文件创建java虚拟机,然后创建引导类加载器(java有多个类加载器,也可自定义类加载器。引导类加载器主要负责加载类库的类),引导类加载器是由c实现的。此后继续创建JVM程序入口类sun.misc.Launcher。由它去初始化其他类加载器。在类运行是也会由launcher找到这个类属于哪个类加载器,而后由此类加载它即可。
Launcher 初始化类其他加载器:
类加载到方法区后,除了有运行时常量池、类型信息、字段信息、方法信息外还会有类加载器的引用、对应class实例的引用。
类加载器引用:加载这个类的类加载器的引用。
Class实例引用:类加载器在加载类信息放到方法区中后,会创建一个对应的Class 类型的对象实例(实例是存放于堆中的),改引用就是指向的堆中实例的引用。
虚拟机对类的加载是用时加载,所以要注意,在类方法执行过程中,用到哪个类才会加载哪个。
类的卸载:
一般不需要考虑类的卸载。类的卸载发生在GC时候。当代表XXX类的Class对象不在被引用,Class对象就会结束生命周期,XXX类在方法区内的数据也会被卸载.JAVA虚拟机自带的三种类加载器(引导类/扩展类/应用程序)加载的类在虚拟机的整个生命周期中是不会被卸载的。而这些类加载器则会始终引用他们所加载类的Class对象,所以它们始终是Root根的可达对象。除非是用户自定义的类加载器。
双亲委派机制类加载机制:
java类加载器有以下几种:
加载某个类时会先委托父加载器寻找目标类,找不到再委托上层父加载器加载,如果所有父加载器在自己的加载类路径下都找不到目标类,则在自己的类加载路径中查找并载入目标类。正常我们没有自定义类加载器的时候:首先会找应用程序类加载器加载,应用程序类加载器发现他不是顶级的类加载器,就会找上一级类加载器加载,直到找到引导类加载器加载。引导类加载器发现这个类不在它的加载范围就会一层层往下委托加载,直到委托到应用程序类加载器自己加载。实现源码看ClassLoader类的loadClass方法:
为何要设计这种类加载机制:
1、保证核心的类不被串改。
2、防止重复加载,父类加载了,子类就不需要再进行加载。
看下第一点:保证核心jdk类不被串改:自定义与String类相同的类,包路径也完全一致:
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("*********这是我修改的 String Class*****");
}
}
运行后报错:java.lang.String 没有 main 方法。当加载我们自己定义的String类的时候,会往上委托加载,最顶层Bootstrap类加载器一看自己已经加载过java.lang.String(jdk)这个类,所以不会去再加载我们自己定义的String类,所以当你调用自己一类的String类中的方法,很明显jdk的String类没有main方法。
全盘负责委托机制:
除了双亲委派,在某个类加载器在加载类的时候,除非显式的指定加载器加载,否则将由加载主类的加载器加载它所引用的所有类,这就是说的全盘委托机制。比如我们某个自己写的一个类A,引用到了String类,和一个我们自定义的其他类B。主类A就负责去加载String类和B类。但是如何加载,就是双亲委派。全盘委托是负责调用所有依赖类的.loadClass方法。
类加载还采用了Cache机制
:如果cache中保存了这个Class就直接返回它,如果没有才从文件中读取和转换成Class,并存入cache。所以。。。。你光修改.class文件是没用的,必须要重启才能重新加载类。这也是为啥我们后来有热部署的原因。
破坏双亲委派机制:
双亲委派局限性:jdk提供了DriverManager连接管理类,要加载各个实现了Driver接口的实现类(比如mysql的连接),然后进行管理,但是DriverManager由引导类类加载器加载,只能加载lib下文件,而其实现是由各个持久层框架实现的,由系统类加载器加载。因此。。。问题来了。。。这个时候,我们就需要其他加载器加载(由引导类加载器委托其他加载器加载),其实这里就破坏了双亲委派的机制。
由父加载器委托子加载器加载类打破双亲机制DriverManager类:load方法其实是获取当前线程上下文加载器加载,而线程上下文加载器是由上面讲的Launche类初始化的时候放进去的,放得是应用类加载器,所以最终Driver是由应用类加载器加载的。
自定义类加载器破坏双亲委派:根据ClassLoader的代码可以看出,要破坏它的双亲委派机制其实就是重新classLoad方法即可(在方法里不向上委派即可)。典型的可以去了解下Tomcat的WebappClassLoader加载器。保证了不同app直接的类对象不“串门”。试想下,如果是双亲委派,不同应用部署在同一个tomcat,那一个用的HashMap是1.7的版本,一个是1.8的版本。。。杂么办。网络tomcat加载类图:
特别提醒:同一个JVM内,两个相同包名和类名的类对象可以共存,因为他们的类加载器可以不一样,所以说,看两个类对象是否是同一个,除了看类的包名和类名是否都相同之外,还需要他们的类加载器也是同一个才能认为他们是同一个。
说下Tomcat的JasperLoader热加载:JasperLoader加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件。当这个文件被修改,监听线程会重新生成新的JasperLoader加载器赋值给引用,然后加载新的jsp对应的servlet类。之前那个加载器因为没有root引用,下次Gc就没了。
围绕一张图:
本地方法区:native方法。
程序计数器:记录每个线程内部的计数器的数值,其值的修改由字节码执行引擎执行。
栈:也叫线程栈。每个线程都有一块独立的线程栈内存。之所以叫栈可以看出线程的方法的执行其实就是一个类似先进后出的方法栈。主要保存的是局部变量表,操作数栈,动态链接,方法出口
堆:堆是所有线程共享的,主要用来存储对象。其中,堆可分为:年轻和老年代
方法区:以前方法区的实现是永久代,现在是元空间。注意方法区是jvm定义的规范,其具体实现在不同虚拟机,同一虚拟机的不同版本都可能不同。线程共享的,主要存储类信息、运行是常量、常量池、静态变量、JIT编译后的代码等数据
常量池:方法区中比较特殊的一个。存的是符号变量以及字面量和class常量池。比如int a=100.其中的100就是字面量。符号引用上面提过,就是表示类中接口变量的唯一内部标识,如方法的全限定名,字段名称等。当类刚被加载字节码时这些符号应用是静态的文件而已,只有到运行时被加载到内存后(运行时常量池),这些代码在运行时会将符号引用转成真实的内存地址信息就叫动态链接。转换主要是通过对象头里的类元信息指针。
一些大量使用的频繁的常用对象如String,Byte,Short,Integer,Long,Character,Boolean等(浮点没有)都设计了常量池的概念,不过这类严格讲属于对象池(1.8 运行是常量池在堆,如刚提到的几个基本数据类型的包装类字符常量依然在堆里面)。不过过大的数字是没有池设计的。2的7次方范围。当我们执行String s ="s" 会先从常量池看此s在池中是否存在而不是去首先创建S。
看下以执行一个Math类的main方法为列会在虚拟机中产生怎样的互动:
1、线程1执行main方法。装载类信息到元空间。在栈中开辟一块内存区域(咱叫它线程栈)。
2、线程栈分三块,本地方法栈,程序计数器(记录你执行到哪一步了)、栈帧。
3、字节码执行引擎执行第一行字节码,修改线程栈中的程序计数器的值,同时修改程序计数器对应的线程对应的执行代码数的值。
3、执行main方法前初始化局部变量表。对象类(Math)型则创建一个对象在推中,然后引用指向它(不考虑内存逃逸)。注意变量表第一个变量其实就是当前对象的实例this。如果在执行中对变量进行操作,则在操作数栈进行(如定义 a=b*c . 先取出b和c的值到操作数表中,然后进行运算。完事后将结果赋值给变量表里的a)
4、Math对象自然会有一个引用指向元空间的字节码文件,如果Math对象还用到了元空间的其他静态方法,也会指向它
5、主方法中调用的方法,被调用方法的栈帧中有存储着方法出口,把放的调用结果返回到主方法中去。
6、栈帧中的动态链接,可以理解为无法在编译期间确定的引用。打个比方,A继承B,C也继承B。都实现了B的某个方法,那调用这个方法的时候只有现在运行的时候才知道实际是调用A里面的还是B里面的还是C里面的方法,其存储的就是实际使用时的方法引用。
7、我们常将的虚拟机内存优化,其实主要就是堆的优化。根据业务需要在有限的内存中来减少少GC次数和GC的时间。
堆:
分为新生代Young和老年代Old。新生代又分为新生Eden区和幸存Survivor区。S区又分为S0和S1。Eden区、Survivor两个区默认比例是8:1:1。Gc大概流程:当Eden区满了之后,触发Minor GC,存活下来的对象移动到Survivor0区。当Eden又满了之后,又循环刚才的步骤。Survivor0区满后触发执行Minor GC,Survivor0区存活对象移动到Suvivor1区。在一下次Eden区满了之后将会将Eden区的存活对象放入到Suvivor1区。Suvivor1区满了之后又会将存活的Suvivor1区对象放入到Suvivor0区。经历一段时间任然存活的对象将被放到老年代。老年代满了将进行full GC。GC的过程其实也就是优化的过程。这里只是略微的提及一下流程。
JVM常见启动参数:
-Xss:每个线程的栈大小
-Xms:初始堆大小,默认物理内存的1/64
-Xmx:最大堆大小,默认物理内存的1/4
默认空余堆内存小于 40% 时,JVM 就会增大堆直到-Xmx 的最大限制;空余堆内存大于 70% 时,JVM 会减少堆直到 -Xms 的最小限制;因此服务器一般设置-Xms、-Xmx 相等以避免在每次 GC 后调整堆的大小
-Xmn:新生代大小(设置后新生代的最小最大值都是此值)
-XX:NewSize:设置新生代初始大小
-XX:MaxNewSize:设置新生代最大大小
-XX:NewRatio:设置年轻代和老年代比列。默认2表示新生代占年老代的1/2,即堆内存的1/3。
-XX:SurvivorRatio:设置新生区和幸存区比列,默认8。表示一个survivor区占用1/8的Eden内存。
关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N
-XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。
-XX:MetaspaceSize: 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M左右,达到该值就会触发full gc进行类卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般将这两个值都设置为256M。
JVM运行模式:(这里说的不是编译class文件,是编译成01机器码)
1、解释模式(Interpreted Mode):只使用解释器(-Xint 强制JVM使用解释模式),执行一行JVM字节码就编译一行为机器码(启动快)。
2、编译模式(Compiled Mode):只使用编译器(-Xcomp JVM使用编译模式),先将所有JVM字节码一次编译为机器码,然后一次性执行所有机器码(启动慢,执行快)
3、混合模式(Mixed Mode):依然使用解释模式执行代码,但是对于一些 "热点" 代码采用编译模式执行,JVM一般采用混合模式执行代码
对象逃逸分析:也叫内存逃逸分析。上面讲到的我们new的对象大部分是在堆上面的。为什么是大部分呢。因为1.7以后,如果对象的作用范围在方法内可以确定不会被其他地方引用到,那就会直接在栈内存里开辟空间去存储而不会放在堆里面。
压缩指针:首先要知道java对象是以8字节的整数倍最小单元存储的,就是如果你的对象不足8字节,也会有对其填充位补充到8字节。这是JVM综合测试考虑的结果,也是典型的以空间换时间的例子。有人说直接8个字节存储对象的内存指针是不是太浪费了。确实,8字节会浪费,不仅是浪费,太大的最小单位会造成GC频率的增加,而且会因为对象大会造成可cpu可缓存数据变小,降低了cpu效率。因此,才会有指针压缩的概念。
打个比方:如果是4个字节为最小单位,那么4个字节,就是最大2的32次方个地址。(看到这里 你是否想到了为啥我们以前32位的操作系统只支持最大4G的内存了呢)cpu寻址最小单位为byte。所以最大的话可以支持 4G内存的地址存储。(其实最小单位是bit,只不过内存8个bit是一组组成了byte)
很明显4G明细是不够用了。因此可以用4字节存储对象又能增大使用内存的方法就称为压缩指针。存一个内部地址映射表,将8byte(为啥是8前面已经讲了,空间换时间的最优值)映射成一个地址,那这样就可以支持4*8=32G的内存空间了。那大于32G的堆内存会咋样。很明显,4字节不够用,压缩指针失效,转为8字节的存储。如此一来GC频率的增加,CPU效率降低。所以说堆内存的内存地址不要大于32G。-XX:+PrintFlagsInitial可以参看压缩指针是否生效。
三:JVM内存分配和垃圾回收算法
要想合理的分配JVM内存,了解其内存分配规则以及内存回收算法是关键第一步。
1、上面已经提到过,一般情况下,新生对象会放到Eden区。当Eden无法再放新对象时将YongGc回收无用的对象,并将存活的对象放入到survivor区。多次GC任然存活的对象将被移入到到老年代。老年代满了将进行FullGc,回收所有的区的无用内存,防止老年代快速满员是关键。
2、大对象直接进入到老年代。-XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代。(G1收集器此参数不生效,生效就怪了o(*^@^*)o)原因就不用说了,为啥大对象直接进入到老年代,主要是为了减少大对象在survivor区两边来回复制效率问题。
3、长期存活的老不死对象进入到老年代:既然采用了分代收集的思想,那虚拟机就是为每个对象分配了一个个对象年龄(Age)计数器。每 Survivor中移动一次,年龄+1.默认15次进入到老年代区域。-XX:MaxTenuringThreshold可以设置老年代的年龄阀值。
4、动态年龄判断:如果survivor区里的对象有年龄1、2、3、4、5、6、7个年龄段,如果1、2、3、4、5年龄段加起来已经超过了50%,那么6、7年龄段的对象会被直接移入到老年代。这个设计其实是希望那些非常可能是长期存活的对象,尽早进入老年代,减少不必要的复制开销。通常此判断是在YongGc之后验证。
5、YongGc后存活的对象Survivor区放不下:这种情况下会将存活的对象部分挪到老年代,部分可能还会放在Survivor区
6、老年代空间担保:每次YongGc之前JVM都会计算老年代的剩余空间。如果这个可用空间小于年轻代里现有的所有对象大小之和!!!!!则查看-XX:-HandlePromotionFailure是否有设置(1.8默认设置),如果设置了,则看老年代的剩余空间A,是否大于之前每一次YongGc后进入老年代的对象的平均大小B。如果A小于B,则直接进行FullGc,如果执行完还是没有足够空间存放,则直接发生OOM。(刚才的参数要是要是没有设置也会直接先进行FullGc)。原因:虚拟机认为这次YongGc很有可能导致老年代空间不够放从而触发FullGc,所以直接在YoungGc之前进行直接进行FullGc,从而减少一次YongGc。
7、Eden与Survivor区的默认比例是8:1:1。其目的也很明了,Eden大可以尽可能的减少YongGc的次数,而Survivor只要保证够用即可。1.8默认使用 UseParallelGC 垃圾回收器,该垃圾回收器默认启动了 AdaptiveSizePolicy,会根据GC的情况自动计算。有时候Survivor区会被调整的很小。因此,因此对于大流量、低延迟系统,建议关闭该参数。当然实际情况还是要看实际业务测试。
如何判断对象可以回收?
引用计数法:对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。看上去简单高效,但却基本主流的虚拟机没有用此算法。原因很简单,相互引用就会导致内存泄漏。下面这种情况,其实objA和objB都已经没用了,但是其任然无法被回收,因为A、B中的引用计数都不为0。
可达性分析算法:通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象。可作为GC Root对象大概有:
被标记的对象并非肯定会被回收的的对象。可达性分析算法中对象会有两次标记,第一次是标记的时候会判断此对象是否有必要执行finalize()方法。第二次标记的时候,如果这个对象被覆盖了finalize方法,并且在第二次标记的时候与引用对象做了关联,则会被移除标记名单。
方法区主要回收的是无用的类,那么如何判断一个类是无用的类呢?同时满足三个条件:
无用对象被标记后,如何收集回收呢?当下虚拟机都是以 分代收集理论 为每个年代用不同算法,目前主流复制算法、标记整理和标记清除
分代收集理论 :根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。比如在新生代中,每次收集都会有绝大部分对象被回收,所以可以选择复制算法,对存活的少量对象的复制就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记清除”或“标记整理”算法进行垃圾收集。
复制算法:以年轻代为例,年轻代有新生区和幸存区。新生区通常比较大,因为新生区的对象一次GC后存活的比列非常非常的小,因为我们可以在将存活对象标记后,将那些存活对象复制到幸存区,然后一次性将新生区的对象全部清除。两个幸存区的对象转换也是如此。
标记清除:标记存活的对象, 回收所有未被标记的对象;也可以反过来,但会带来两个明显的问题:可能要标记的对象非常多,耗时。又还有比较清楚后会有很多内存碎片,因为要清楚对象和存活对象不是连续分布的。优点除了简单,没想到。
标记整理:与标记清除不同的是,在标记后会将不可回收对象向一块内存区移动形成连续的内存空间。而后对边界以外的空间一次性清除。
垃圾收集器就是这些算法的具体实现、
Serial收集器:单线程收集器,先暂停所有用户线程,然后单线程复制算法进行YOUNG-GC,老年代Serial Old收集器同样是单线程,不过使用的是标记整理算法。
Parallel Scavenge收集器:多线程版的Serial收集器,新生代采用复制算法,老年代采用标记-整理算法收集线程数跟cpu核数相同。专注于提高cpu的使用率,单要保证STY时间还要根据系统慢慢的调整参数。Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法。JDK8的默认垃圾收集器。
ParNew收集器:与上面的Parallel类似。
CMS收集器:低延迟首选,真正意义上的并行收集器。采用标记清楚算法,是老年代的收集器。
缺点:在并发标记和并发清理阶段又产生垃圾浮动垃圾没法清理;碎片;如果在一次收集未结束的过程中又进行第二次的垃圾回收,这时候没办法只能使用单线程的serial old垃圾收集器来回收
碎片清理可以设置多少次fullGc之后进行碎片压缩,默认每次都压缩。
“三色标记”:把Gcroots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:
实际的JVM调优不可能对着GC日志一行一行看,在不是非常熟悉的业务系统的情况下,JVM调优基本不大可能,只能是解决一些突发的问题。推荐 Arthas使用:https://arthas.aliyun.com/doc/
还有个GC日志的分析工具。不过收费的。通过导入GC日志,会给你调优建议https://gceasy.io/