类加载+双亲委派
main方法运行过程
C++语言实现的java.exe来创建jvm,和引导类加载器,并由引导类加载器来创建类加载器的启动器launcher,在类加载器启动器空参构造中就对剩下的拓展类加载器,应用程序加载器,和自定义加载器来进行了加载(本质上也是一个URL类加载器,通过类的路径来加载),加载完成之后,就会调用main方法,来启动程序;
类加载过程
加载:
将磁盘上的class文件,通过类加载器,加载到内存中,并生成一个class对象,包含了class文件中的信息;属性方法等;
类被加载到方法区后,主要包括,运行时常量池,类型信息,字段信息,方法信息,类加载器的引用,对应class实例的引用等信息;
类加载器的引用:这个类加载器实例的引用
对应class实例的引用:类加载器在加载类信息放到方法区中后,会创建一个对应的class类型的对象实例放到堆(heap)中,作为开发人员访问方法区中类定义的入口和切入点;
Jdk进行类加载的机制可以看成懒加载,只有在真正用到的时候才回去加载;
验证:
验证文件的格式的正确性,class字节码文件是16进制文件;
字节码文件(要符合一定规则)
比如开头的cafe babe标志,以及上面的0034转换成16进制就是52,代表JDK版本为JDK8;
准备:
对于加载阶段创建的class对象,因为静态变量属于class对象,这时就可以对静态变量分配内存,赋予默认值;
解析:
静态链接的过程;
符号引用:java文件中的一些关键字,变量类型,变量名,符号等,在解析的时候都会放到常量池,成为静态变量
直接引用:真实的内存地址
对于一些静态方法,或者java定义好的修饰符,关键字,符号等替换为直接引用,因为这些是JDK就规定好了的,一旦被加载到内存,它的地址不会改变,这就是静态链接;
对于
Java -p java提供的查看字节码文件工具,更便于我们浏览
Constant pool常量池,#1这种相当于它的标识,相当于序号,也可以说是key
Methodref:方法引用
Invokespecial在进行方法调用
动态链接:Math.compute();中compute在加载的时候不会去解析,真正运行的时候才会区解析,才会把符号链接到在内存中的真正地址;
可以理解为属于JDK,或者class对象的符号,用的是静态链接,属于正常的bean对象的用的是动态链接;
初始化
对静态变量 进行初始化,并执行静态代码块;
类加载过程中,执行静态代码块,在进行构造初始化init;
类加载器和双亲委派机制
类的加载过程主要有类加载器来完成,类加载器有
- 引导类加载器bootstrapclassloader,加载负责支撑JVM运行的核心类库,经常用到的JDK自带的类,String,Math等,debug查看它为null,因为它是c实现的,不是一个java对象;
- 拓展类加载器extclassloader,加载支撑JVM运行的拓展jar,lib下的ext包
- 应用程序加载器appclassloader,加载程序中classpath,也就是JVM自带的之外的自定义的类
- 用户自定义类加载器,加载指定的类
类加载器中,都会保存一份它的父的类加载器,
查看引导类加载器加载的路径;
这个是appclassloader,加载的包,它不仅加载了我们定义的,它还包含了JDK的核心类库包,以及拓展类包,上面说核心类库由引导类加载器加载,拓展类包由拓展类加载器extclassloader加载,而应用程序加载器appclassloader的加载包中却有这些,这里就引出了双亲委派机制;
双亲委派加载过程图解
- 向上委托查找,查看当前加载器是否加载过该类,没有则交给父加载器加载;
- 委托查找时,每个加载器加载过的类,都会有一个缓存,向上查找就是查找加载的class缓存;
- 向下加载,当顶层加载器引导类加载器,都没有加载过,那就让引导类加载器进行加载,如果有加载权限,就加载,没有加载权限,则交给子加载器加载;
向上查找就是为了保护JDK自己的类,不被用户自定义的加载器加载;而且为了保护不被其它自定义的加载器加载,还有个沙箱保护机制,当我们自定义类的包名,不允许和JDK定义好的包名一致,在类加载的过程中会进行判断;
全盘负责委托机制
当一个classloader装载一个类时,除非显示的使用另外一个classloader,装载该类时,需要引用的其它类也由该classloader加载;
打破双亲委派机制
双亲委派的代码,在classloader中实现的,向上查找,向下加载,findclass()
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {//这里 先去判断父类是否为空,为空说明是引导类加载器,向上查找的时候,查到引导类加载器的时候,
if (parent != null) {
c = parent.loadClass(name, false);
} else {
//这里去引导类加载器中去查找
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
//如果引导类加载器也没有加载过,
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
//这里正真的去findclass加载
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
以上源码可以看出,它先查找了当前类加载器有没有加载,没加载再去父类loadeclass()加载,
所以这里我们可以继承一个loadclass,重写loadclass方法,小小的改动,只要让它查找不到的时候不去用父类去loadeclass,而是直接findclass();修改后如下;
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
//这里直接当前加载器加载
findclass(name);
if (resolve) {
resolveClass(c);
}
return c;
}
}
这里直接这么改的话,会出现问题,因为之前提到的全盘负责委托机制,我们定义的类中,它所有的类都由我们自定义的加载器加载,而有的类只能是引导类加载器加载,所以我们只需要在原来的基础上做小改动
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//这里添加条件 当然我这里只是粗略的判断了包名
if(name.contains("com.chenlei")){
c = findClass(name);
}else {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
Tomcat的一个WEB容器,可能是需要部署多个应用程序,多个war包,不同的应用程序,可能依赖的同一个第三方类库的不同版本,因此要保证每一个应用有它自己的独立的类库,来保证相互隔离;
Tomcat的应用隔离,它每个应用有自己的webappclassloader,用来加载自己目录下的class文件,不会传递给父类加载器,打破了双亲委派机制;
热部署原理,当加载了类过后,卸载加载器,然后再次需要的话,重新加载;
同一个JVM中,不同路径下,包名相同,类名相同,不同的classloader;这就相当于tomcat加载
JVM内存模型
JVM虚拟机的组成
类装载子系统
执行引擎
运行时数据区
-
类装载子系统
class文件,通过类装载子系统,把class文件放到了数据区,然后通过执行引擎来执行;
堆(Heap)
GC:执行引擎在后台会维护一个GC线程来做对象回收;
堆分为年轻代和老年代,年轻代又分为eden(伊甸园区)和servivor区,它们的默认内存大小比例为老年代:年轻代=2:1;eden:s0:s1=8:1:1;
新建的对象,都会放入Eden中,当Eden中的内存地址被对象放满之后,就会触发young_GC,从GC_ROOT根节点根据引用链来判断是否是需要回收的对象;如果是存在引用,那么这个对象以及引用链上的对象都会认为是有效对象,就会从Eden区移动到servivor区,并且分代年龄加1;剩余的留在Eden区的对象就会被认为是垃圾对象,进行回收;
当Eden重复被放满的情况下,有效对象会在s0和s1之间来回挪动,每次挪动分代年龄+1,当分代年龄达到一定值后,就会向把对象放在老年代中,或者来回挪动时,s0或者s1放不下了,也会放到老年代中;
当老年代被放满时,就会触发一次full_GC,对整个堆和方法区来进行一次回收,当回收过后,老年代还是放满的,这时再有对象放入老年代时,就会OOM;
Java 提供的JVM监控控工具 jvisualvm
STW机制
Stop the world,当jvm进行GC的时候,会把工作线程全部暂停,用户就会感觉是程序卡了一样,特别是FULL_GC它扫描的需要进行GC的区域比较大,STW的时间就会更长;
设置STW机制的原因,个人理解,有点像数据库各级级别中的可重复读,可重复读它可以粗略的理解成是一个快照,每次读的数据一样;这里就是要确保扫描结果的准确性,和一致性(GC完之后和此时内存中对象状态一致),但这里它是直接不再产生新对象,避免扫描的结果不确定;比如一边生成对象,或者方法出栈,局部变量对象的引用发生改变,一边之前扫描过的对象前一秒还是有效的,后一瞬间就变成了垃圾对象,之前的扫描感觉就像是无效的;
栈(stack)
不同的线程,在执行代码的时候,都会有自己的栈(所以也可以称为线程栈),来存放当前线程的局部变量,当方法被执行时,也会马上在在线程栈中分配一个栈帧,把不同方法的局部变量隔离开来;
方法进栈的原则是先进后出,后进先出;因为后进栈的方法先执行完,执行完它先出栈的话,它局部变量也就用完了,就能先释放掉内存空间;
栈帧:
方法进栈时分配的内存,主要含有局部变量表,操作数栈,动态链接,方法出口
局部变量表:
存放局部变量,其实是局部变量在内存中的地址,真正的局部变量对象放在堆中;
操作数栈:
需要进行修改计算等操作的数据的值,都是加载到操作数栈中,然后CPU从操作数栈中去取要进行操作的变量,然后把操作计算完的结果再压回操作数栈中;相当于一个临时中转站;
动态链接:
在运行的时候,存放调用的非静态的方法在内存中的实际地址;
方法出口:
记录当前方法调用完毕,下面该执行哪一行;
程序计数器
用来记录当前线程代码执行的位置;因为代码是由执行引擎来执行的,它执行完一行代码,字节码执行引擎都会去修改它到对应的位置;
这主要是考虑到多线程的情况,便于进行线程恢复的时候定位;
注:紫色的部分是每个线程都会有的,不会共享;
黄色是所有线程共享的;
方法区(元空间)
运行时常量(比如final修饰的)+静态变量+类信息,比如 static User user= new User();这时user对象就是一个静态变量,它就会在方法区中,存放一份user对象在堆中的地址;
本地方法栈
本地方法是指,JDK提供的,由java去调用其它语言的接口,当本地方法调用的时候,不管什么语言实现的,它都会在内存中分配内存空间,这个内存空间就是分配在本地方法栈;
也就是线程在调用本地方法的时候分配的内存空间;
总结
jvm虚拟机中,堆和方法区是所有线程共享的部分;
栈和程序计数器是线程创建的时候,分配的,如果要进行一些本地方法的调用,线程还会分配一个本地方法栈,这些在线程之间都不是共享的;
方法区中静态变量,如果这个静态变量刚好是我们创建的对象,也是只存放了堆中创建对象的地址;
JVM内存参数设置
-XX:MetaspaceSize
-XX:MaxMetaspaceSize
对于方法区,它存放了类信息,它直接使用的是物理内存,而且有一个内存空间的自动伸缩机制,我们不去设置值的时候,它的默认值是21M,当21M内存被放满后,就会触发FULL_GC,会根据FULL_GC之后,释放的空间大小,判断对它的值进行扩大还是缩小;所以合理设置元空间的初始内存和最大内存,能有效减少FULL_GC次数;
-Xss
它也是直接使用的是物理内存,是用来设置每个线程分配的栈空间的大小,默认为1M,当方法不停的嵌套,然后进栈不出栈的情况下,栈总会被放满的,合理设置可以减少程序出错,导致递归调用的次数,也能满足日常调用而尽可能少的出现栈溢出情况;
而且栈分配的小,同等情况下,理论上能开启的线程数也越多;
对象创建过程
内存分配:
划分内存的方法,
指针碰撞:在JVM的堆中,对象是有序存放的,空闲内存和占用内存是有一个指针作为分界点,当新建一个对象的时候,就把分界点挪动和新建对象大小一样的距离;
空闲列表:JVM堆中的对象并不是有序繁殖的,所以JVM维护一个列表,来标识哪些内存空间还是空闲状态,分配的时候从空闲里面划分一个能放置新建对象的内存,然后更新列表;
不管是哪种分配方式,当多个线程去创建对象,同时需要在堆中进行内存分配,就会存在并发的问题;
内存分配时并发问题的解决
CAS
几个线程同时抢占这一块空间,谁抢到谁使用,没抢到的加上重试机制;、
本地线程分配缓冲
-XX:+/-UsrTLAB 设置虚拟机是否开启本地线程分配缓冲
-XX:TLABSize 指定TLAB的大小;默认是伊甸园区的1%;
提前给线程在堆中分配好一块专属的内存空间;JDK8默认开启;
初始化
属于对象的成员变量,也会先给一个默认的初始值,
设置对象头
标记字段(Mark Word):
类型指针(Klass pointer):堆内存中对象头里边,存放的地址,指向方法区中的类信息;
类信息:粗略理解为存放了class代码信息,用c和c++实现的对象;而class对象是提供给java开发人员的一个java对象,放在堆中;
对象在内存中的分布,其中object header是对象头,除了后边有一个alignment是对其补充,其它的是实例数据;aliment/padding是实例数据内部的对其;为了满足对象大小为8bit的倍数,这样读取效率更高;
对象的指针压缩
将对象头中的类型指针,和数据进行指针压缩,减小对象占用内存的大小,JDK1.6之后默认开启的;
内存和操作系统位数的关系
内存是需要指针来进行表示的,32位指针,用二进制来表示就是能存2的32次方个数,也就是能代表2的32次方个内存地址,转换成内存大小就是4个G;
正常32位的指针,只能表示4个G的内存,如果是16G的内存,本来是用2的35次方也就是至少35位的操作系统,来表示,但是有了指针压缩过后,就能用32位地址,就能支持16G内存;
当堆内存大于32G的时候,指针压缩会失效,会强制使用64位,来堆java空对象寻址,
Init方法
给对象的成员变量真正赋值,并调用构造方法;
对象内存分配
TLAB指开启本地线程分配缓冲,在伊甸园区提前分配堆内存,来提高并发;
-XX:+DoEscapeAnalysis逃逸分析
JVM可以通过设置-XX:+DoEscapeAnalysis,来确定是否开启逃逸分析,在进行内存分配的时候,会先去进行对象逃逸分析;JDK7之后默认开启;
对象逃逸分析:也就是分析对象的作用域,当一个对象在方法中被定义,如果它可能会在别的地方引用了,那么就可以人为它的作用域逃出了当前方法;比如test1,当它不会再别的地方被引用,作用域很确定只在当前方法中,那么就认为它没有逃逸;
对于没有逃逸的对象,就可以在栈上的方法的栈帧内存里分配内存空间,当方法出栈的时候,对象占用的内存空间随着出栈就销毁了,减轻了GC的压力;
所以不是所有的创建的对象都在堆内存中;
XX:+EliminateAllocations标量替换:
通过逃逸分析,不会逃逸的对象,并且可以被进一步分解时,JVM不会创建该对象,可以在栈帧中分配内存空间,但是栈帧的内存空间本来就不够大,可能没有一块连续的空间来存储当前对象,它就把对象拆分,然后放置到栈帧中的不连续的内存空间中;
逃逸分析和标量替换同时打开的时候,对于会逃逸的对象,在栈上分配内存,对于不逃逸的对象,进行标量替换来分解对象,也放在栈上这样可能会大量减少GC次数;
对象在年轻代中的分配 -XXUserAdaptiveSizePolicy年轻代内存分配自动变化
Young GC:新生代发生的垃圾搜集动作,回收频繁,回收速度快;
Full GC:回收堆中老年代,年轻代和方法区中的垃圾;一般回收速度会慢10倍以上;
因为Eden区的对象大多都是朝生夕死,存活时间很短,所以年轻代中让Eden区尽量大是合理的,默认8:1:1,JVM默认开启-XXUserAdaptiveSizePolicy,会导致比例自动变化,不想自动变化可以关闭;
大对象直接进入老年代
大对象是指需要大量连续空间的对象,JVM参数 -XX:PretenureSizeThreshold可以定义大对象的大小,超过这个值,直接放到老年代中,这个参数只在Serial(-XX:UseSerialGC)和ParNew两个收集器中有效;
避免在年轻代中来回复制,大对象复制慢,降低性能;
长期存活的对象尽早放进老年代
可以通过设置对象年龄参数,来控制长期存活数据放入老年代的时间,如果大部分对象都是一次性的,或者是长期存活的对象,就可以适当调小年龄,来让对象尽早进入老年代;通过参数-XX:MaxTenuringThreshold来设置;
对象动态年龄判断机制
当一批对象从Eden区,或者从一个servivor区,放入另一个servivor区时,如果这一批对象的总大小,大于servivor区的50%,那么准备放入对象的这个servivor区中,年龄大于这一批对象中最大的年龄的数据,都会放入老年代;这就是动态年龄判断机制,它 一般实在有young GC之后触发
对象内存回收
如何确定对象是否位垃圾对象;
- 引用计数算法
当对象,在内存中,每有一个引用就对它进行引用计数+1;当引用失效就-1,当引用计数为0时,对象被视为垃圾对象;JVM一般不用这种算法,它比较难解决对象之间的相互引用的问题;
- 可达性分析算法
将GC-ROOT对象作为起点,从这个的节点向下搜索引用对象,找到的对象标为非垃圾对象,
GC-ROOT根节点:线程栈的本地变量,静态变量,本地方法栈的变量等;
常见的引用类型
方法区中回收主要回收无用的类
除了自定义加载器,JDK提供的Classloader 几乎不会被回收掉,所以如果是通过应用类加载器,拓展类加载器,和引导类加载器来加载的class,那么它只要加载过一次,方法区中的类就不会被回收;Tomcat热部署是通过卸载加载器,来实现的,类加载器是重新创建的,所以这种自定义加载器加载的类是可以进行回收的;