JVM体系结构图
一、方法区
1.是线程间共享的内存区域
2.存储了被虚拟机加载的类的信息(包括类的类型、修饰符、方法信息、字段信息)、类的静态变量、final类型的常量、类中的字段、方法数据信息、构造函数、以及编译器编译后的代码内容等
但是以上只是一个规范,在不同虚拟机里实现是不一样的。
如:List list=new ArrayList();
List就是规范,ArrayList对应不同的虚拟机的不同实现。
典型的两种就是”永久代“(JDK8以前)和”元空间“(JDK8及以后)
实例变量存储在堆中,与方法区无关。
运行时常量池
运行时常量池是方法区的一部分。用于存放编译时产生的各种字面量和符号引用。
顾名思义,运行时产生的常量自然也会存储在这里(动态性)。
如String的intern()方法,如果字符串常量池中已经包含一个等于此String对象的字符串,则会返回该对象的一个引用,否则会将该String对象包含的字符串添加到常量池中。
测试:
public static void main(String[] args) {
String sb=new StringBuilder("哈哈").append("嘿嘿").toString();
System.out.println(sb.intern()==sb);//true
String sb2=new StringBuilder("ja").append("va").toString();
//java在Version类中已经出现,字符串常量池已经有它的引用,因此返回false
System.out.println(sb2.intern()==sb2);//false
}
如下:
package sun.misc;
public class Version {
private static final String launcher_name = "java";
private static final String java_version = "1.8.0_231";
..............................
}
该类在启动类加载器(Bootstrap ClassLoader)中被加载,位于rt.jar\sun\misc中:
异常情况
方法区无法满足新的内存分配需求时,会抛出OOM异常。
运行时常量池申请不到内存时,会抛出OOM异常。
二、栈
栈负责运行(堆负责存储)
1.线程私有的,与线程同生命周期,不存在垃圾回收问题
2.每个方法执行会创建一个栈帧保存在栈的顶部,每个方法从调用到执行完毕对应一个栈帧从入栈到出栈的过程。
如下:
public static void m1(){
System.out.println("m1..begin");
m2();
System.out.println("m1..end");
}
public static void m2(){
System.out.println("m2");
}
public static void main(String[] args) {
System.out.println("main...begin");
m1();
System.out.println("main...end");
}
对应的栈模型如下:
| |
| m2() |
| m1() |
| main() |
3.栈帧用于存储局部变量表、操作数栈、动态连接和方法出口等信息。
栈(或者说是局部变量表部分)保存的信息如下:
8种基本类型变量+对象引用变量+实例方法
局部变量表:存储方法中的局部变量(方法中声明的非静态变量,入参和出参等)。8种基本类型变量直接存储对应的值,而对象引用变量存储指向对象起始地址的引用指针,或是指向一个代表对象的句柄或其他与对象相关的位置)
指向运行时常量池的引用:当我们在方法中用到类中的常量时,就需要一个指向运行时常量池的引用。
操作数栈:记录方法中所有计算相关的出栈、入栈的操作
方法出口:即方法结束后,我们需要知道之前调用它的地方,因此需要保存一个方法返回的地址。
栈的相关异常
1.线程请求的栈的大小大于虚拟机允许的大小,会出现StackOverflowError错误。
2.栈申请动态扩展无法获取到足够内存时会出现OOM异常。(目前的HotSpot虚拟机不可以进行动态扩展,因此线程申请栈空间成功后就一定不会出现OOM异常,但申请失败仍会出现OOM)。
栈异常测试:
针对情况一,可以将栈的大小调小:
出现StackOverflowError
public class VMStackSOF {
private static int stackLength = 0;
//-Xss150k 减少栈内存容量 StackOverFlow
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) {
VMStackSOF oom=new VMStackSOF();
try {
oom.stackLeak();
}catch (Throwable e){
System.out.println("栈长度:"+oom.stackLength);
throw e;
}
}
}
或是将方法帧的本地变量表变地超大:
public class VMStackSOF {
private static int stackLength = 0;
//增大方法帧中本地变量表的长度 StackOverFlowError
public static void test() {
long a1,.......a100;//100个局部变量
stackLength++;
test();
a1 =a2=.....a100;
}
public static void main(String[] args) {
VMStackSOF oom=new VMStackSOF();
try {
test();
}catch (Error a){
System.out.println("长度"+stackLength);
throw a;
}
}
}
三、堆
1.为线程间共享,虚拟机启动时创建
2.用于存放对象实例以及数组
3.是垃圾收集器管理的内存区域,也称为“GC堆”。
但需要注意的是,随着即使编译技术的进步,尤其是逃逸分析技术的日渐强大,也可以进行栈上分配以及TLAB。
这里可以扩展下:
关于栈上分配:
有很多对象的作用于并不会逃逸出方法外,而如果在堆上创建,则当方法的调用结束后,对象的生命周期也随之结束,则相应地指向堆中该对象的引用也就没有了,GC就会去回收该对象,若这种对象非常多,就会增加GC的压力。
此时我们就可以将这类对象属性打散后分配在栈中,这样方法调用结束后,栈空间的回收就会将打散后的对象回收掉。这样就避免了给GC增加压力,提高性能。
关于TLAB:
由于堆是全局共享的内存区域,而创建对象又是非常频繁的行为,因此多个线程并发给对象分配内存时就需要进行同步(jvm采用的是CAS失败重试的方式保证原子性的),而这个开销在某些情况是非常大的。
而TLAB(Thread Local Allocation Buffer)就是在堆中划分出多个线程私有的分配缓冲区,就可以让线程分配堆空间时先分配到自己所属的缓冲区,避免同步带来的开销,以提高对象分配的效率。(jvm默认开启,也可以使用-XX: +UseTLAB
显示开启),但当对象过大时仍然直接在堆上分配空间。
使用-XX:+PrintTLAB
参数打开跟踪TLAB的使用情况,
使用-XX:TLABSize
通过该参数指定分配给每一个线程的TLAB空间的大小
那么有个问题,假如TLAB大小为100KB,如果还剩10KB,要给一个20KB对象分配,那么肯定不够,这里就有两种解决方式:
1.在eden区域分配;
2.取消当前TLAB,然后再申请一个空间足够的新TLAB
而无论采用哪种方式,如果频繁的废弃TLAB或者频繁的在堆分配内存,也必须进行同步控制,因此就会失去使用TLAB的意义。
因此提供了一个refill_waste
参数,即“最大浪费空间”,假如要分配的空间大于该值,则使用方案1,否则使用方案2.
参数:-XX:TLABRefillWasteFraction
调整该值,默认64
堆空间的构成
堆由老年代和年轻代构成,而年轻代又分为eden区和两个survivor区(from Space and To Space)。
对象首先配分配到年轻代的eden区,但如果对象大于eden区小于老年区,则直接丢在老年区,如果比老年代还大,则抛出OOM异常。
在survivor区经过多次GC仍然存活的对象,被转移到老年区。
堆的大小设定
通过 -Xms 最小值 和 -Xmx最大值控制堆的大小。
当 -Xms和-Xmx相同时,堆是固定大小不可自动扩展的,否则是能够自动扩展的
异常
当堆中内存不足以分配对象空间且堆也无法扩展时,会抛出OOM异常。
异常测试
通过以上两个参数将堆大小控制在10MB,再加上-XX:+HeapDumpOnOutOfMemoryError
参数使得在出现OOM时,dump出当前的内存堆转储快照。
public class HeapOOM {
static class OOM{
}
/**
* -Xms10m -Xmx10m -XX:+HeapDumpOnOutOfMemoryError
* -XX:+HeapDumpOnOutOfMemoryError 让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照
* @param args
*/
public static void main(String[] args) {
/*
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid9368.hprof ...
Heap dump file created [28275886 bytes in 0.140 secs]
*/
List<OOM> list=new ArrayList<>();
while (true){
list.add(new OOM());
}
}
}
我们使用IDEA集成的插件jprofiler运行,打开得到的堆转储快照,可以迅速找出异常位置。
四、程序计数器
可以看做是当前线程锁执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来选取下一个需要执行的字节码指令。
由于在java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何时刻一个处理器都只会执行一个线程中的指令。
因此为了切换线程后能恢复到切换前的执行位置,每条线程都需要有一个独立的程序计数器,互不干扰,独立存储。
因此程序计数器是线程私有的。
如果执行的是一个java方法,则记录的是正在执行的虚拟机字节码的指令地址。
如果执行的是本地native方法,则计数器值为undefined。
不会发生OOM异常
五、本地方法栈
同java栈作用基本一样,但是本地方法栈是为了支持本地native方法,而栈是为java方法服务。
异常出现情况同栈相同。
六、对象的创建
这里只讨论普通java对象,不包括数组和Class对象。
当虚拟机遇到一条new指令时:
1.new指令
检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那么须先执行相应的类加载过程。
2.分配内存
对象所需内存大小在类初始化完成后可以完全确定。
分配内存的方式有两种:
a.指针碰撞(Bump The Pointer)
堆内存是规整的时候,一边是使用过的内存,一边是空闲的内存,中间使用一个指针作为指示器,分配内配就是将指针向空闲的一边移动和对象大小相同的距离。
b.空闲列表(Free List)
堆内存不是规整的时候,虚拟机维护一张列表,记录了哪些内存块是可用的,分配内存就是从列表中找到一块足够大的空间划分给对象实例,并更新列表内容。
分配方式由堆是否规整决定,堆是否规整由垃圾收集器是否能够进行空间压缩整理决定。
3.初始化
将分配到的内存空间(不包括对象头)初始化为零值。(TLAB方式可提前至TLAB分配时进行)保证对象实例字段不赋初始值就可以直接使用。
4.对象的初始设置
如:对象是哪个实例、如何找到类的元数据信息、对象的哈希码、GC分代年龄等。
以上信息存放在对象头(Object Header)中。根据虚拟机当前的运行状态的不同,如对否启用偏向锁等,对象头会有不同的设置方式。
5. <init>()方法
new指令会接着执行Class文件的init方法,即构造函数,来将对象的其他资源和状态信息构造好。此时才算是构造一个真正可用的对象。
七、对象的内存布局
在HotSpot中,对象在堆内存中的存储布局可划分为三个部分:
对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
7.1 对象头
主要分为两类信息:
-
用于存储对象自身的运行时数据
哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳。
-
类型指针
对象指向它的类型元数据的指针,该指针确定对象时哪个类的实例。(但查找对象的元数据信息并非一定要经过对象本身)。
如果对象是一个java数组,则对象头还需要有一块记录数组的长度。
7.2 实例数据
存储了对象的有效信息。即在代码中定义的各种类型的字段内容(父类继承下来的和子类的都会记录下来)。
存储顺序受虚拟机分配策略参数 -XX:FieldsAllocationStyle 和字段定义顺序影响。
默认的分配顺序为:
longs/doubles、ints、shorts/charts、bytes/booleans、oops(Ordinary Object Pointers),可以看到相同宽度的字段总是被分配到一起存放。
7.3 对齐填充
虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象的大小必须为8字节整数倍,如果实例数据没有对齐的话,需要通过对齐填充来作为占位符补全。
八、对象的访问定位
java通过栈上的reference操作堆中的具体对象。
方式主要有两种:句柄和直接指针
8.1句柄访问
在堆中划分出一块内存作为句柄池, reference存储句柄地址。
句柄包含了对象的实例数据和类型数据各自的地址信息。
8.2 直接指针
reference直接存储对象地址。如果只访问对象本身,则避免了一次简介访问的开销。
优势
句柄: reference中存储的是稳定句柄地址,对象被移动时,智慧该病句柄中的实例数据指针。
直接指针:速度更快,节省了一次指针定位的时间开销。在频繁的java对象访问中,这种开销非常可观。
HotSpot主要采用直接指针进行对象访问。
======================================================================
其他相关笔记:
JVM笔记(二)对象的生死与java的四大引用
JVM笔记(三)垃圾收集算法以及HotSpot的算法实现(安全点、记忆集与卡表、写屏障、三色标记等)
JVM笔记《四》七个常见的垃圾收集器
JVM笔记(五)类加载机制、类加载器和双亲委派机制
================================================================
参考:
《深入理解java虚拟机第三版》