类加载机制
字节码如何产生的
类的生命周期
- 加载:将.class文件从磁盘读到内存。
- 连接
- 验证:验证字节码文件的正确性
- 准备:给类的静态变量分配内存,并赋予初始值
- 解析:类装载器装入类的引用和其他所有类
- 初始化:为类的静态变量赋予正确的初始值,上述的准备阶段为静态变量赋予的是虚拟机默认的初始值,此处赋予的才是程序编写者为变量分配的真正的初始值,执行静态代码块
- 使用
- 卸载
类加载器的种类
-
启动类加载器(Bootstrap ClassLoader):负责加载JRE的核心类库,如JRE目标下的rt.jar,charsets.jar等
//null Bootstrap ClassLoader是使用C写的,无法直接体现出来 System.out.println(String.class.getClassLoader());
-
扩展类加载器(Extension ClassLoader):负责加载JRE扩展目录ext中jar类包
//sun.misc.Launcher$ExtClassLoader System.out.println(DESKeyFactory.class.getClassLoader().getClass().getName());
-
系统类加载器(Application ClassLoader):负责加载ClassPath路径下的类包
//sun.misc.Launcher$AppClassLoader System.out.println(JVMDemo.class.getClassLoader().getClass().getName()); //sun.misc.Launcher$AppClassLoader System.out.println(ClassLoader.getSystemClassLoader().getClass().getName());
-
用户自定义加载器(User ClassLoader):负责加载用户自定义路径下的类包
类加载器的顺序
类加载顺序不是继承关系,是向上委托的关系
类加载机制
全盘负责委托机制
当一个ClassLoader加载一个类的时候,除非显示的使用另一个ClassLoader,该类所依赖和引用的类也由这个ClassLoader载入
双亲委派机制
指先委托父类加载器寻找目标类,在找不到的情况下加 载自己的路径中查找并载入目标类
双亲委派模式的优势
- 沙箱安全机制:比如自己写的String.class类不会被加载,这样可以防止核心库被随意篡改
- 避免类的重复加载:当父ClassLoader已经加载了该类的时候,就不需要子CJlassLoader再加载一次
如何打破双亲委派机制
为了避免双亲委托机制,我们可以自己定义一个类加载器,然后重写 loadClass()
即可。
JVM结构
jvm由三个主要的子系统构成
- 类加载子系统
- 运行时数据区(内存结构)
- 执行引擎
运行时数据区(内存结构)
方法区(Method Area-jdk1.8之后不存在)
方法区是一个抽象的概念,在HotSpot中叫做永久代,在JRockit叫做(元空间)
类的所有字段和方法字节码,以及一些特殊方法如构造函数,接口代码也在这里定义。简单来说,所有定义的方法的信息都保存在该区域,静态变量+常量+类信息(构造方法/接口定义)+运行时常量池都存在方法区中,虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是为了和Java的堆区分开。
堆(Heap)
Heap堆区是Java发生OOM(Out Of Memory)故障的地方,堆中存储着我们平时创建的实例对象,最终这些不再使用的对象会被垃圾收集器回收掉,而且堆是线程共享的。一般情况下,堆所占用的内存空间是JVM内存区域中最大的,我们在平时编码中,创建对象如果不加以克制,内存空间也会被耗尽。堆的内存空间是可以自定义大小的,同时也支持在运行时动态修改,通过 -Xms 、-Xmx 这两参数去改变堆的初始值和最大值。-X
指的是JVM运行参数,ms
是memory start的简称,代表的是最小堆容量,mx
是memory max的简称,代表的是最大堆容量
;如 -Xms256M代表堆的初始值是256M,-Xmx1024M代表堆的最大值是1024M。由于堆的内存空间是可以动态调整的,所以在服务器运行的时候,请求流量的不确定性可能会导致我们堆的内存空间不断调整,会增加服务器的压力,所以我们一般都会将JVM的Xms
和Xmx
的值设置成一样,同样也为了避免在GC
(垃圾回收)之后调整堆大小时带来的额外压力。
堆区分为两大区:Young区和Old区,又称新生代和老年代。
新生代
对象刚创建的时候,会被创建在新生代
,到一定阶段之后会移送至老年代,如果创建了一个新生代无法容纳的新对象,那么这个新对象也可以创建到老年代。如上图所示。新生代
分为1个Eden
区和2个S区
,S代表Survivor
。大部分的对象会在Eden区
中生成,当Eden区没有足够的空间容纳新对象时,会触发Young Garbage Collection,即YGC(Minor GC)。在Eden区进行垃圾清除时,它的策略是会把没有引用的对象直接给回收掉,还有引用的对象会被移送到Survivor区
。Survivor区有S0
和S1
两个内存空间,每次进行YGC的时候,会将存活的对象复制到未使用的那块内存空间,然后将当前正在使用的空间完全清除掉,再交换两个空间的使用状况。如果YGC要移送的对象Survivor区无法容纳,那么就会将该对象直接移交给老年代。
新生代占比堆内存3/1
Eden :From :To=8:1:1。
动态对象年龄判定
为了更好地适应不同程序内存状况,虚拟机并不硬性要求对象年龄达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入年老代。
老年代
上面说了,到一定阶段
的对象会移送到老年区,这是什么意思呢?每一个对象都有一个计数器,当每次进行YGC的时候,都会 +1
。通过-XX:MAXTenuringThrehold
参数可以配置当计数器的值到达某个阈值时,对象就会从新生代移送至老年代。该参数的默认值为15,也就是说对象在Survivor区中的S0和S1内存空间交换的次数累加到15(最大只能是15.因为Mark Word分代年龄只有4bit)次之后,就会移送至老年代
。如果参数配置为1,那么创建的对象就会直接移送至老年代。老年代占堆内存3/2,具体的对象分配即回收流程可观看下图所示。
若老年代也满了,这时候将发生Major GC(也可以叫Full GC),进行老年区的内存清理。若老年区执行了Full GC之后发现依然无法进行对象的保存,就会抛出OOM(OutOfMemoryError)异常
如果Survivor
区无法放下,或者创建了一个超大新对象,Eden
和Old
区都无法存放,就会触发Full Garbage Collection,即FGG
,便再尝试放在Old
区,如果还是容纳不了,就会抛出OOM
异常。在不同的JVM实现及不同的回收机制中,堆内存的划分方式是不一样的。
元空间(Meta Space)
在JDK8版本中,元空间的前身Pern
区已经被淘汰。在JDK7及之前的版本中,Hotspot
还有Pern
区,翻译为永久代,在启动时就已经确定了大小,难以进行调优,并且只有FGC
时会移动类元信息。不同于之前版本的Pern
(永久代),JDK8的元空间
已经在本地内存
中进行分配,并且,Pern
区中的所有内容中字符串常量
移至堆内存
,其他内容也包括了类元信息(构造方法/接口定义)
、字段
、静态属性
、方法
、常量
等等都移至元空间
内。
为什么移除了永久代?
大概意思是移除永久代是为融合HotSpot与 JRockit而做出的努力,因为JRockit没有永久代,不需要配置永久代
栈(Stack)
Java线程执行方法的内存模型,一个线程对应一个栈,每个方法在执行的同时都会创建一个栈帧(用于存储局部变量表,操作数栈,动态链接,方法出口等信息)不存在垃圾回收问题,只要线程一结束该栈就释放,生命周期和线程一致。LIFO(先进后出)
虚拟机栈通过压栈
和出栈
的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另外一个栈帧上。在执行的过程中,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定。栈帧在整个JVM体系中的地位颇高,包括局部变量表
、操作栈
、动态连接
、方法返回地址
等。
局部变量表
局部变量表是存放方法参数
和局部变量
的区域。我们都知道,类属性变量一共要经历两个阶段,分为准备阶段
和初始化阶段
,而局部变量是没有准备阶段,只有初始化阶段
,而且必须是显式
的。如果是非静态方法,则在index[0]位置上存储的是方法所属对象的实例引用
,随后存储的是参数
和局部变量
。字节码指令中的STORE指令就是将操作栈中计算完成的局部变量写回局部变量表的存储空间内
。
操作数栈
操作栈是一个初始状态为空的桶式结构栈
。在方法执行过程中,会有各种指令往栈中写入和提取信息。JVM的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈
。字节码指令集的定义都是基于栈类型
的,栈的深度在方法元信息的stack属性中,下面就通过一个例子来说明下操作栈与局部变量表的交互:
public int add() {
int x = 10;
int y = 20;
int z = x + y;
return z;
}
public int add();
Code:
0: bipush 10 // 常量 10 压入操作栈
2: istore_1 // 并保存到局部变量表的 slot_1 中 (第 1 处)
3: bipush 20 // 常量 20 压入操作栈
5: istore_2 // 并保存到局部变量表的 slot_2 中
6: iload_1 // 把局部变量表的 slot_1 元素(int x)压入操作栈
7: iload_2 // 把局部变量表的 slot_2 元素(int y)压入操作栈
8: iadd // 把上方的两个数都取出来,在 CPU 里加一下,并压回操作栈的栈顶
9: istore_3 // 把栈顶的结果存储到局部变量表的 slot_3 中
10: iload_3
11: ireturn // 返回栈顶元素值
动态链接
Map<String,String> map=new HashMap();
map.put("1","2")
局部变量表中的map引用的是堆里面实例化出来的HashMap。
在调用put方法时候调用的是HashMap的put方法。
每个栈帧中包含一个在常量池中对当前方法的引用
,目的是支持方法调用过程的动态连接
。
方法出口
方法的连续调用,方法出口记录的方法(也有可能是线程)的内存地址。方法执行时有两种退出情况:第一,正常退出,即正常执行到任何方法的返回字节码指令
,如 RETURN
、IRETURN
、ARETURN
等;第二,异常退出。无论何种退出情况,都将返回方法当前被调用的位置
。方法退出的过程相当于弹出当前栈帧,而退出可能有三种方式:
- 返回值压入上层调用栈帧。
- 异常信息抛给能够处理的栈帧。
- PC 计数器指向方法调用后的下一条指令。
本地方法栈(Native Method Stack)
和栈作用很相似,区别不过是Java栈为JVM执行Java方法服务,而本地方法栈为JVM执行native方法服务。登记native方法,在Execution Engine执行时加载本地方法库
程序计数器(Program Counter Register)
在程序计数寄存器(Program Counter Register,PC)中,Register的命名源于CPU的寄存器,CPU只有把数据装载到寄存器才能够运行
。寄存器存储指令相关的现场信息,由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一个指令。这样必然会导致经常中断或恢复,如何才能保证分毫无差呢?每个线程在创建之后,都会产生自己的程序计数器
和栈帧
,程序计数器
用来存放执行指令的偏移量和行号指示器等(存储指向下一条指令的地址,也即将要执行的指令代码)
,线程执行或恢复都要依赖程序计数器
。程序计数器在各个线程之间互不影响,此区域也不会发生内存溢出异常
。
栈堆方法区的关系
区别
内存分配
栈:由编译器自动分配和释放,存放函数的参数、局部变量、临时变量、函数返回地址等。
堆:一般人为分配和释放,对Java而言由系统释放回收,但对于C++等,必须手动释放,如果没有手动释放会引起内存泄漏。
系统响应
栈:只要栈的剩余空间大于所申请的空间,系统将为程序提供内存,否则将报异常提示栈溢出。
堆:在记录空闲内存地址的链表中寻找一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。
大小限制
栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在 windows下,栈的大小是2M,如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。
堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。
结论:堆获得的空间比较灵活,也比较大。
分配效率
栈:由系统自动分配,速度较快,无法人为控制。
堆:由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。
存储内容
栈:在栈中,第一个进栈的是主函数下一条指令的地址,然后是函数的各个参数,在大多数编译器中,参数是由右往左入栈,然后是函数中的局部变量。注意,静态变量不入栈。出栈则刚好顺序相反。
堆:一般在堆的头部用一个字节存放堆的大小,具体内容受人为控制。