一.类加载器
虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块
称为“类加载器”。
类加载生命周期七阶段:
- 类加载:类加载器将class文件加载到虚拟机的内存
- 加载:在硬盘上查找并通过IO读入字节码文件
- 连接:执行校验,准备,解析(可选)步骤
- 校验:校验字节码文件的正确性
- 准备:给类的静态变量分配内存,并赋予默认值
- 解析:类加载器装入类所引用的其他类
- 初始化:对类的静态变量初始化为指定的值,执行静态代码块
类与类加载器
- 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。即使两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
常见加载器
从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类
java.lang.ClassLoader。
从java开发人员角度,类加载器可以分为三类:
- 启动类加载器:Bootstrap ClassLoader,C++语言实现,负责加载JDK中的核心类库。这个类加载器负责将存放<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可
- 扩展类加载器:Extension ClassLoader,java实现,负责加载JDK\jre\lib\ext目录,或者由java.ext.dirs系统变量指定路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器
- 应用程序类加载器 Application ClassLoader,负责加载应用程序classpath目录下的所有jar和class文件。开发者可以直接使用该类加载器。
除了Java默认提供的三个ClassLoader之外,用户还可以根据需要定义自已的ClassLoader,而这些自定义的ClassLoader都必须继承自java.lang.ClassLoader类,也包括Java提供的另外二个ClassLoader(Extension ClassLoader和App ClassLoader)在内,但是Bootstrap ClassLoader不继承自ClassLoader,因为它不是一个普通的Java类,底层由C++编写,已嵌入到了JVM内核当中,当JVM启动后,Bootstrap ClassLoader也随着启动,负责加载完核心类库后,并构造Extension ClassLoader和App ClassLoader类加载器
双亲委托模型
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器
工作过程
如果一个类加载器收到了类加载的请
求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。如果没有找到,会抛出ClassNotFoundException。
优点
避免重复加载,保证Java程序的稳定运作。Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。如果不使用这种委托模式,那我们就可以随时使用自定义的Object来动态替代java核心api中定义的类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况
自定义classLoader
为什么要自定义类加载器
Java中提供的默认ClassLoader,只加载指定目录下的jar和class,如果我们想加载其它位置的类或jar时,比如:我要加载网络上的一个class文件,通过动态加载到内存之后,要调用这个类中的方法实现我的业务逻辑。在这样的情况下,默认的ClassLoader就不能满足我们的需求了,所以需要定义自己的ClassLoader
怎么定义
- 继承java.lang.ClassLoader
- 重写父类的findClass方法
父类有那么多方法,为什么偏偏只重写findClass方法?
- 因为JDK已经在loadClass方法中帮我们实现了ClassLoader搜索类的算法,当在loadClass方法中搜索不到类时,loadClass方法就会调用findClass方法来搜索类,所以我们只需重写该方法即可。如没有特殊的要求,一般不建议重写loadClass搜索类的算法。下图是API中ClassLoader的loadClass方法:

二. 运行时数据区
线程独占区的虚拟机栈,本地方法栈,程序计数器都随线程的生死而生死
1. 虚拟机栈
java线程执行方法的内存模型,一个线程对应一个栈,栈的生命周期与线程相同。每个方法在执行的同时都会创建一个栈帧(用于存储局部变量表,操作数栈,动态链接,方法出口等信息)不存在垃圾回收问题,只要线程一结束该栈就释放
虚拟机栈规定了两种异常 - 如果线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverflowError异常
- 如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常
1
2
3
4
5
6
7
8
9
10
11
12
13
14public class App{
public int add(){
int a=1;
int b=2;
int c=(a+b)*100;
return c;
}
public static void main String[]args){
App app=new App();
int result=app.add();
System.out.plintln(result);
}
}

局部变量表存放编译时期可知的各种基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用(存放指针),和returnAdddress类型(指向一条字节码指令的地址)
局部变量表所需内存空间在编译期间完成分配,在方法运行期间不会改变局部变量表的大小。
64位的long,double会占用2个局部变量空间,其余数据类型只占一个
Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。
Java 方法有两种返回方式:
- return 语句。
- 抛出异常。
不管哪种返回方式都会导致栈帧被弹出。
2. 本地方法栈
结构与虚拟机栈一致,也会抛出StackOverflowError和OutOfMemoryError。本地方法栈的方法都是有native关键字修饰的。区别只是虚拟机栈为执行java方法(字节码)服务,本地方法栈为虚拟机使用到的Native方法服务。(使用native关键字说明这个方法是原生函数,也就是这个方法是用C/C++语言实现的,并且被编译成了DLL,由java去调用。)
本地方法栈登记native方法,在Execution Engine执行时加载本地方法库
3. 程序计数器
一个指针,存储下一个指令的地址,是一个非常小的内存空间。由执行引擎读取下一条指令
在任何确定的时刻,一个内核都只会处理一条线程的指令,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,这是线程私有的内存。
如果线程执行java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行native方法,这个计数器值则为空(Undefined)。
程序计数器是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError的内存区域
4. 方法区(线程共享)/非堆
存储已被虚拟机加载的静态变量+常量+类信息+即时编译器编译后的代码等数据,类的所有字段和方法字节码,以及一些特殊方法如构造函数,接口代码也在此定义。
java虚拟机规范把方法区描述为堆的一个逻辑部分,但是方法区有个别名叫non-heap与堆区分开
方法区可以选择不实现垃圾收集器。这区域的回收目标是针对常量池的回收和对类型的卸载
运行时常量池
运行时常量池是方法区的一部分,用于存放编译器生成的各种字面量和符号引用。
5. 堆
虚拟机启动时自动创建,用于存放对象实例,几乎所有对象实例以及数组都在堆上分配内存。java堆只要逻辑上连续即可,可以物理不连续
当对象无法在该空间申请到内存,堆也无法扩展时抛出OutOfMemoryError异常
是垃圾收集器管理的主要区域。
可通过-Xmx -Xms参数来分别指定最大堆,最小堆
Java堆还可以细分为:新生代和老年代。新生代再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
eden 区、s0 区、s1 区都属于新生代,tentired 区属于老年代。大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
从内存分配的角度看,线程共享的java堆可能划分出多个线程私有的分配缓冲区
直接内存
直接内存不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域,但是这部分内存也被频繁使用,也可能出现OutOfMemoryError
JDK1.4加入NIO(new input/ouput)类,引入了一种基于通道和缓存区的I/O方式,它可以使用native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景显著提高性能,因为避免了在java堆和native堆中来回复制数据
三. HotSpot虚拟机对象揭秘
对象的创建
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象
所需内存的大小在类加载完成后便可完全确定
为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)。
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
选择哪种分配方式由Java堆是否规整决
定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类
的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始——<init>方法还没有执行,所有的字段都还为零。所以,一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对
象才算完全产生出来。对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头
HotSpot虚拟机的对象头包括两部分信息
第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为”Mark Word”。对象需要存储的运行时数据很多,其实已经超出了
32位、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,MarkWord被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的
32bit空间中的25bit用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,而在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容见表

- 对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。
实例数据部分
是对象真正存储的有效信息,也是在程序代
码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、
bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果CompactFields参数值为true(默认为true),那么子类之中较窄的变量也可能会插入到父类变
量的空隙之中。对齐填充
并不是必然存在的,也没有特别的含义,它仅仅
起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问
Java程序需要通过栈上的reference数据来操作堆上的具体对象。
句柄访问
Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
优势:reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。
直接指针访问
如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址
优势:速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本
OutOfMemoryError异常
堆溢出 Java heap space
设置虚拟机参数JVM堆初始堆大小,最大堆大小都为20m,不可扩展
1
-Xms20M -Xmx20M
测试
1
2
3
4
5
6
7
8
9
10
11
12
13package OOM;
import java.util.ArrayList;
import java.util.List;
public class HeapOOM{
static class OOMObject{
}
public static void main(String[]args){
List<OOMObject> list=new ArrayList<OOMObject>();
while(true){
list.add(new OOMObject());
}
}
}运行结果
当出现Java堆内存溢出时,异常堆栈信息”java.lang.OutOfMemoryError”会
跟着进一步提示”Java heap space”。1
2
3
4
5
6
7
8
9
10"C:\Program Files\Java\jdk-9.0.4\bin\java.exe" -Xms20M -Xmx20M "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2018.1.8\lib\idea_rt.jar=14378:C:\Program Files\JetBrains\IntelliJ IDEA 2018.1.8\bin" -Dfile.encoding=UTF-8 -classpath D:\myfiles\workspace\java\java_data_structure\target\classes OOM.HeapOOM
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3719)
at java.base/java.util.Arrays.copyOf(Arrays.java:3688)
at java.base/java.util.ArrayList.grow(ArrayList.java:237)
at java.base/java.util.ArrayList.grow(ArrayList.java:242)
at java.base/java.util.ArrayList.add(ArrayList.java:467)
at java.base/java.util.ArrayList.add(ArrayList.java:480)
at OOM.HeapOOM.main(HeapOOM.java:16)
Process finished with exit code 1解决方案
要解决这个区域的异常,一般的手段是先通过内存映像分析工具
(Eclipse有Eclipse Memory Analyzer;IDEA的JProfiler;jmeter)对Dump出来的堆转储快照进行分析,重
点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了
内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)垃圾收集器与内存分配策略
垃圾收集主要是针对堆和方法区进行。程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线
程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收。判断对象是否存活
1. 引用计数算法
为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被
回收。
在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为循环引用的存
在,因此 Java 虚拟机不使用引用计数算法。
1 | public class Test { |
- 可达性分析算法
以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。

可以作为GC Root的对象
- 虚拟机栈中的引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI(native方法)的引用的对象
引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关
引用分为强引用
(Strong Reference)、软引用(Soft Reference)、弱引用(Weak
Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐
渐减弱。
强引用
就是指在程序代码之中普遍存在的,类似Object obj=new
Object()这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉
被引用的对象。
软引用
是用来描述一些还有用但并非必需的对象。对于软引用关联
着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回
收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛
出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引
用。
1 | Object obj = new Object(); |
弱引用
也是用来描述非必需对象的,但是它的强度比软引用更弱一
些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾
收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的
对象。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
1 | Object obj = new Object(); |
虚引用
也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。
1 | Object obj = new Object(); |
回收条件
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GCRoots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将
会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收
了
回收方法区
方法区中进行垃圾收集的“性价比”一般比较低
垃圾回收算法
新生代:复制算法
缺点:空间折半
优点:简单,没有内存碎片
老年代:标记整理算法
老年代:标记清理算法
常见回收器
- Serial Garbage Collector:单线程GC
- Parallel Garbage Collector:多线程GC
- CMS Garbage Collector:多线程GC
- G1 Garbage Collector:jdk7引进GC,多线程,高并发,低暂停,逐步取代CMS GC
执行引擎
什么是JVM
JVM JDK JRE区别
JVM指令集
javap -c app.class app.txt
JVM性能调优:堆
jvm 分代回收
如何判断对象是否需要回收:
引用计数算法
GC ROOT
JVM性能优化
1 | //该程序的stop work 永远不会被输出 |
每一个线程都会放在一个虚拟机栈中
volatile是C/C++实现的
java并发
CAS 线程池
线程安全
AQS
并行与并发
锁
如下几种情况,java虚拟机将结束生命周期:
- 执行了System.exit()方法
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致java虚拟机进程终止
参考:
- 《深入理解Java虚拟机》
- 深入分析Java ClassLoader原理:https://blog.csdn.net/xyang81/article/details/7292380
- Java的自定义类加载器及JVM自带的类加载器之间的交互关系:https://blog.csdn.net/MDreamlove/article/details/79212420
JVM内存模型与JMM(Java内存模型)不是一回事