- 第三章 运行时数据区概述及线程
- 第四章 程序计数器(PC寄存器)
- 第五章 虚拟机栈
- 8 = Class #32 :去找 #32
- 32 = Utf8 com/atguigu/java1/DynamicLinkingTest
- 31 = NameAndType #19:#13 :去找 #19 和 #13
- 19 = Utf8 methodA :方法名为 methodA
- 13 = Utf8 ()V :方法没有形参,返回值为 void
- 第六章 本地方法接口
- 第七章 本地方法栈
- 第八章 堆
- 第九章 方法区
- 第十章 对象的实例化内存布局与访问定位
- 第十一章 直接(本地)内存
第三章 运行时数据区概述及线程
本节主要讲的是运行时数据区,也就是下图这部分,它是在类加载完成后的阶段
当我们通过前面的:类的加载 —> 验证 —> 准备 —> 解析 —> 初始化,这几个阶段完成后,就会用到执行引擎对我们的类进行使用,同时执行引擎将会使用到我们运行时数据区
类比一下也就是大厨做饭,我们把大厨后面的东西(切好的菜,刀,调料),比作是运行时数据区。而厨师可以类比于执行引擎,将通过准备的东西进行制作成精美的菜品。
1、运行时数据区结构
1.1 运行时数据区与内存
- 内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,来探讨一下经典的JVM内存布局。
- 我们通过磁盘或者网络IO得到的数据,都需要先加载到内存中,然后CPU从内存中获取数据进行读取,也就是说内存充当了CPU和磁盘之间的桥梁
1.2 线程的内存空间
- Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区:其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。
- 灰色的为单独线程私有的,红色的为多个线程共享的。即:
- 线程独有:独立包括程序计数器、栈、本地方法栈
- 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)
1.3 Runtime类
每个JVM只有一个Runtime实例。即为运行时环境,相当于内存结构的中间的那个框框:运行时数据区。Runtime是单例的。
2、线程
2.1 Java线程
- 线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行
- 在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射
- 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收,释放资源
- 操作系统负责将线程安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法
2.2 JVM 系统线程
如果你使用jconsole或者是任何一个调试工具,都能看到在后台有许多线程在运行。这些后台线程不包括调用public static void main(String[])的main线程以及所有这个main线程自己创建的线程。
这些主要的后台系统线程在Hotspot JVM里主要是以下几个:
- 虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型括”stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销
- 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行
- GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持
- 编译线程:这种线程在运行时会将字节码编译成到本地代码
- 信号调度线程:这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理
第四章 程序计数器(PC寄存器)
1、PC寄存器介绍
官方文档网址:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html
- JVM中的程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。
- 这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。
- 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。
- 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefned)。
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。 程序计数器可以理解为游标或集合的迭代器
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
- 它是唯一一个在Java虚拟机规范中没有规定任何OutofMemoryError情况的区域。 只有堆和方法区才有垃圾回收,只有堆、方法区和栈可能存在OOM。
PC寄存器的作用:PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令,并执行该指令。
2、举例说明
public class PCRegisterTest {
public static void main(String[] args) {
int i = 10;
int j = 20;
int k = i + j;
String s = "abc";
System.out.println(i);
System.out.println(k);
}
}
看字节码的方法:https://blog.csdn.net/21aspnet/article/details/88351875
左边的数字代表指令地址、偏移地址,属于PC寄存器存储的结构,即PC寄存器中可能存储的值,然后执行引擎读取 PC 寄存器中的值,并执行该指令
3、两个常见问题
3.1 使用PC寄存器存储字节码指令地址有什么用呢?或者问为什么使用 PC 寄存器来记录当前线程的执行地址呢?
- 因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行
- JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令
3.2 PC寄存器为什么被设定为私有的?
- 我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。
- 由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。
这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
4、CPU时间片
CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。
- 在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。
- 但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。
第五章 虚拟机栈
1、虚拟机栈概述
1.1 虚拟机栈的出现背景
- 由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的【如果设计成基于寄存器的,耦合度高,性能会有所提升,因为可以对具体的CPU架构进行优化,但是跨平台性大大降低】。
优点是跨平台,指令集小,编译器容易实现。缺点是性能下降,实现同样的功能需要更多的指令。
1.2 内存中的栈与堆
栈是运行时的单位,而堆是存储的单位。
- 即:栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放,放哪里
- 对象放在堆中,方法内的局部变量(基本数据类型)放在栈中、局部变量(引用数据类型)只在栈空间中放对象的引用
1.3 虚拟机栈基本内容
(1)Java虚拟机栈是什么?
- Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用,
栈是线程私有的
public class StackTest {
public static void main(String[] args) {
StackTest test = new StackTest();
test.methodA();
}
public void methodA() {
int i = 10;
int j = 20;
methodB();
}
public void methodB(){
int k = 30;
int m = 40;
}
}
(2)虚拟机栈的生命周期生命周期和线程一致,也就是线程结束了,该虚拟机栈也销毁了。主线程结束,整个虚拟机栈也就结束了
(3)虚拟机栈的作用
- 主管Java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址(对象本身放在堆空间中))、部分结果,并参与方法的调用和返回。
- 局部变量 VS 成员变量(或属性)
- 基本数据类型变量 VS 引用类型变量(类、数组、接口)
(4)虚拟机栈的特点
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
- JVM直接对Java栈的操作只有两个:
- 每个方法执行,伴随着进栈(入栈、压栈)
- 执行结束后的出栈工作
- 对于栈来说不存在垃圾回收问题
- 栈不需要GC,但是可能存在OOM
- 程序计数器不存在GC,不存在OOM
(5)面试题:栈中可能出现的异常?
Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
- 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError 异常。
- 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutofMemoryError 异常。
(6)设置栈内存大小
官方文档看看:https://docs.oracle.com/en/java/javase/11/tools/java.html#GUID-3B1CE181-CD30-4178-9602-230B800D4FAE
我们可以使用参数 -Xss 选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度
Sets the thread stack size (in bytes). Append the letter k or K to indicate KB, m or M to indicate MB, and g or G to indicate GB. The default value depends on the platform:
● Linux/x64 (64-bit): 1024 KB
● macOS (64-bit): 1024 KB
● Oracle Solaris/x64 (64-bit): 1024 KB
● Windows: The default value depends on virtual memory
The following examples set the thread stack size to 1024 KB in different units:
-Xss1m
-Xss1024k
-Xss1048576
/**
* 演示栈中的异常:StackOverflowError
*
* 默认情况下:count : 11407
* 设置栈的大小: -Xss256k : count : 2467
*/
public class StackErrorTest {
private static int count = 1;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args);
}
}
没设置参数栈的大小前:说明栈在11407这个深度溢出了
设置栈参数之后:说明设置栈的大小: -Xss256k起作用了
2、栈的存储单位
2.1 栈中存储什么
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的基本单位存在
- 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame),一个方法的执行对应一个栈帧的入栈,一个方法的结束对应一个栈帧的出栈
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
2.2 栈运行原理
- JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循先进后出(后进先出)原则
- 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的。这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
- 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
Java方法有两种返回函数的方式:
并行每个线程下的栈都是私有的,因此每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要由局部变量表 和 操作数栈决定的
3、局部变量表
3.1 认识局部变量表
- 局部变量表也被称之为局部变量数组或本地变量表
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress返回值类型。
- 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
- 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
- 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。
- 对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。
- 进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
局部变量表中的变量只在当前方法调用中有效。
- 在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。
当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
public class LocalVariablesTest {
private int count = 0;
public static void main(String[] args) {
LocalVariablesTest test = new LocalVariablesTest();
int num = 10;
test.test1();
}
// 练习:
public static void testStatic(){
LocalVariablesTest test = new LocalVariablesTest();
Date date = new Date();
int count = 10;
System.out.println(count);
// 因为this变量不存在于当前方法的局部变量表中!!
// System.out.println(this.count);
}
//关于Slot的使用的理解
public LocalVariablesTest(){
this.count = 1;
}
public void test1() {
Date date = new Date();
String name1 = "atguigu.com";
test2(date, name1);
System.out.println(date + name1);
}
public String test2(Date dateP, String name2) {
dateP = null;
name2 = "songhongkang";
double weight = 130.5;//占据两个slot
char gender = '男';
return dateP + name2;
}
public void test3() {
this.count++;
}
public void test4() {
int a = 0;
{
int b = 0;
b = a + 1;
}
//变量c使用之前已经销毁的变量b占据的slot的位置
int c = a + 1;
}
/** 变量的分类:
* 按照数据类型分:① 基本数据类型 ② 引用数据类型
* 按照在类中声明的位置分:① 成员变量:在使用前,都经历过默认初始化赋值
* 类变量: linking的prepare阶段:给类变量默认赋值 ---> initial阶段:给类变量显式赋值即静态代码块赋值
* 实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
* ② 局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过
*/
public void test5Temp(){
int num;
// System.out.println(num);//错误信息:变量num未进行初始化
}
}
字节码中方法内部结构的剖析
直接用jclasslib来看字节码,以main方法为例来讲解
1、0-15表示有16行字节码指令。即指令的长度为16
2、方法异常信息表
3、Misc
4、行号表:Java代码的行号和字节码指令行号的对应关系
5、局部变量表:生效行数和剩余有效行数都是针对于字节码文件的行数起始PC和长度合起来表示该局部变量的作用域范围
- Start PC = 11表示在字节码的11行开始生效,也就是Java代码对应的第15行。而声明int num在java代码的是第14行,说明是从声明的下一行开始生效
- Length = 5表示局部变量剩余有效行数,main方法字节码指令总共有16行,从11行开始生效,那么剩下就是16-11 = 5。
- Ljava/lang/String 前面的L表示引用类型
3.2 关于Slot的理解
参数值的存放总是从局部变量数组索引 0 的位置开始,到数组长度-1的索引结束。
- 局部变量表,最基本的存储单元是Slot(变量槽)
- 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
- 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型占用两个slot(1ong和double)。
- byte、short、char在储存前被转换为int,boolean也被转换为int,0表示false,非0表示true
- long和double则占据两个slot
- JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
- 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上
- 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或double类型变量)
- 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。(this也相当于一个变量)
3.2.1 Slot代码示例
1、局部变量表:this 存放在 index = 0 的位置
2、64位的类型(1ong和double)占用两个slot
weight为double类型,index只需要记录起始索引即可
3、static无法调用this
this不存在与static方法的局部变量表中,所以无法调用
3.2.2 Slot的重复利用
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明新的局部变量变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
3.3 静态变量与局部变量的对比
变量的分类:
1、按照数据类型分:① 基本数据类型 ② 引用数据类型
2、按照在类中声明的位置分:
2-1、成员变量:在使用前,都经历过默认初始化赋值
2-1-1、类变量: linking的prepare阶段:给类变量默认赋值 —-> initial阶段:给类变量显式赋值即静态代码块赋值
2-1-2、实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
2-2、局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过。
- 参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。
- 我们知道成员变量有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。
和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
3.4 补充说明
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
4、操作数栈
栈:可以使用数组或链表来实现。栈可以理解为是满足特殊条件的数组或链表。
操作数栈就是用数组实现的
局部变量表也是数组结构4.1 操作数栈的特点
每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last - In - First -Out)的操作数栈,也可以称之为表达式栈(Expression Stack)
- 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和出栈(pop)
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈
- 比如:执行复制、交换、求和等操作
4.2 操作数栈的作用
- 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
- 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这时方法的操作数栈(数组)是空的
- 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度(数组的长度)在编译期就定义好了,保存在方法的Code属性中,为maxstack的值。
- 栈中的任何一个元素都是可以任意的Java数据类型
- 32bit的类型占用一个栈单位深度
- 64bit的类型占用两个栈单位深度
- 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。只不过操作数栈是用数组这个结构来实现的而已
- 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
- 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
- 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
4.3 操作数栈的字节码指令执行分析
1、首先执行第一条语句,PC寄存器指向的是0,也就是指令地址为0,然后使用bipush让操作数15入操作数栈。
2、执行完后,PC寄存器往下移,指向下一行代码,下一行代码就是将操作数栈的元素存储到局部变量表1的位置(istore_1),我们可以看到局部变量表的已经增加了一个元素。并且操作数栈为空了
为什么局部变量表索引从1开始:因为该方法为实例方法,局部变量表索引为 0 的位置存放的是 this
3、然后PC下移,指向的是下一行。让操作数8也入栈,同时执行store操作,存入局部变量表中
4、然后从局部变量表中,依次将数据放在操作数栈中,等待执行 add 操作
iload_1:取出局部变量表中索引为1的数据入操作数栈
5、然后将操作数栈中的两个元素执行相加操作(iadd是通过执行引擎加字节码指令转换为机器指令,然后机器指令被CPU进行运算),并存储在局部变量表3的位置
关于类型转换的说明
- 因为int m = 8可以存放在 byte 类型中,所以压入操作数栈的类型为 byte ,而不是 int ,所以执行的字节码指令为 bipush 8
- 但是存储在局部变量的时候,会转成 int 类型的变量:istore_4
- m改成800之后,byte存储不了,就成了short型,sipush 800
如果被调用的方法带有返回值,返回值入操作数栈
1、getSum() 方法字节码指令:最后带着个 ireturn
2、testGetSum() 方法字节码指令:一上来就加载 getSum() 方法的返回值()
5、栈顶缓存技术(Top Of Stack Cashing)
- 前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数(也就是你会发现指令很多)和导致内存读/写次数多,效率不高。
- 由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
寄存器的主要优点:指令更少,执行速度快,但是指令集(也就是指令种类)很多
6、动态链接
动态链接(或指向运行时常量池的方法引用)
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking),比如:invokedynamic指令
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
public class DynamicLinkingTest {
int num = 10;
public void methodA(){
System.out.println("methodA()....");
}
public void methodB(){
System.out.println("methodB()....");
methodA();
num++;
}
}
对应字节码(反编译命令:javap -verbose .\DynamicLinkingTest.class)
1、在字节码指令中,methodB() 方法中通过 invokevirtual #7 指令调用了方法 A ,那么 #7 是个啥呢?
2、往上面翻,找到常量池的定义:#7 = Methodref #8.#31
- 先找 #8 :
- 再来找 #31:
3、结论:通过 #7 我们就能找到需要调用的 methodA() 方法,并进行调用
4、在上面,其实还有很多符号引用,比如Object、System、PrintStream 等等
为什么要用常量池呢?
- 因为在不同的方法,都可能调用常量或者方法,所以只需要存储一份即可,然后记录其引用即可,节省了空间。
常量池的作用:就是为了提供一些符号和常量,便于指令的识别。直接调用起来比较方便
7、方法的调用:解析与分派
7.1 静态链接与动态链接
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关
静态链接:当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期确定,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。7.2 早期绑定与晚期绑定
静态链接与动态链接针对的是方法。早期绑定和晚期绑定范围更广。早期绑定涵盖了静态链接,晚期绑定涵盖了动态链接。
静态链接和动态链接对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
晚期绑定:如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。 ```java class Animal{public void eat(){
System.out.println("动物进食");
} } interface Huntable{ void hunt(); }
class Dog extends Animal implements Huntable{
@Override
public void eat() {
System.out.println("狗吃骨头");
}
@Override
public void hunt() {
System.out.println("捕食耗子,多管闲事");
}
}
class Cat extends Animal implements Huntable{
public Cat(){
super(); // 表现为:早期绑定
}
public Cat(String name){
this(); // 表现为:早期绑定
}
@Override
public void eat() {
super.eat(); // 表现为:早期绑定
System.out.println("猫吃鱼");
}
@Override
public void hunt() {
System.out.println("捕食耗子,天经地义");
}
} public class AnimalTest {
public void showAnimal(Animal animal){
animal.eat(); // 编译器无法确定,表现为:晚期绑定
}
public void showHunt(Huntable h){
h.hunt(); // 编译器无法确定,表现为:晚期绑定
}
}
部分字节码:<br />invokevirtual 体现为晚期绑定<br />invokeinterface 也体现为晚期绑定<br />invokespecial 体现为早期绑定<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650196258552-8cf2ec0d-d717-4a19-b8c8-f3842f706ad1.png#clientId=ue86a20af-6157-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=357&id=u357923b1&margin=%5Bobject%20Object%5D&name=image.png&originHeight=781&originWidth=1488&originalType=binary&ratio=1&rotation=0&showTitle=false&size=137517&status=done&style=none&taskId=u27bef131-9b50-4006-a734-afd8c92b8d7&title=&width=680)<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650196403037-715b7358-db51-49d8-9b36-6327b74dee55.png#clientId=ue86a20af-6157-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=213&id=u1648235a&margin=%5Bobject%20Object%5D&name=image.png&originHeight=488&originWidth=1570&originalType=binary&ratio=1&rotation=0&showTitle=false&size=103782&status=done&style=none&taskId=uc6f5441d-5e39-428f-a06a-544b7617755&title=&width=686)
<a name="DE6xm"></a>
### 7.3 多态与绑定
1. 随着高级语言的横空出世,类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。
1. Java中任何一个普通的方法其实都具备**虚函数(运行时才确定)**的特征,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。
**虚方法与非虚方法**
1. 如果方法在**编译期就确定**了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。
1. 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法(这些方法就不涉及多态性)
1. 其他方法称为虚方法。
**子类对象的多态的使用前提:**
1. 类的继承关系
1. 方法的重写
<a name="jcSwc"></a>
### 7.4 虚拟机中调用方法的指令
**普通指令:**
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
**动态调用指令**
- invokedynamic:动态解析出需要调用的方法,然后执行
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预。而invokedynamic指令则支持由用户确定方法版本。**其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。**
```java
/**
* invokestatic指令和invokespecial指令调用的方法称为非虚方法
*/
class Father {
public Father() {
System.out.println("father的构造器");
}
public static void showStatic(String str) {
System.out.println("father " + str);
}
public final void showFinal() {
System.out.println("father show final");
}
public void showCommon() {
System.out.println("father 普通方法");
}
}
public class Son extends Father {
public Son() {
// invokespecial
super();
}
public Son(int age) {
// invokespecial
this();
}
// 不是重写的父类的静态方法,因为静态方法不能被重写!
public static void showStatic(String str) {
System.out.println("son " + str);
}
private void showPrivate(String str) {
System.out.println("son private" + str);
}
public void show() {
// invokestatic
showStatic("atguigu.com");
// invokestatic
super.showStatic("good!");
// invokespecial
showPrivate("hello!");
// invokespecial
super.showCommon();
//invokevirtual
showFinal(); // 因为此方法声明有final,不能被子类重写,所以也认为此方法是非虚方法。
//虚方法如下:
//invokevirtual
showCommon();
info();
MethodInterface in = null;
// invokeinterface
in.methodA();
}
public void info(){
}
public void display(Father f){
f.showCommon();
}
public static void main(String[] args) {
Son so = new Son();
so.show();
}
}
interface MethodInterface{
void methodA();
}
7.4.1 关于invokedynamic指令
- JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现【动态类型语言】支持而做的一种改进。
- 但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式。
Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器。 ```java @FunctionalInterface interface Func {
public boolean func(String str); }
public class Lambda {
public void lambda(Func func) {
return;
}
public static void main(String[] args) {
Lambda lambda = new Lambda();
Func func = s -> {
return true;
};
lambda.lambda(func);
lambda.lambda(s -> {
return true;
});
}
}
![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650198011025-b303a7fa-897f-42c9-a483-dc566c2cd1d2.png#clientId=ue86a20af-6157-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=687&id=u631e0e46&margin=%5Bobject%20Object%5D&name=image.png&originHeight=687&originWidth=1340&originalType=binary&ratio=1&rotation=0&showTitle=false&size=109969&status=done&style=none&taskId=u2306b683-b2ce-49be-96a3-a3cc3b3f6e0&title=&width=1340)
<a name="nu9Hl"></a>
#### 7.4.2 动态语言和静态语言
1. 动态类型语言和静态类型语言两者的区别就在于**对类型的检查是在编译期还是在运行期**,满足前者就是静态类型语言,反之是动态类型语言。
1. 说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
Java:String info = "mogu blog"; (静态语言。会先编译就进行类型检查) <br />JS:var name = "shkstart"; var name = 10; (动态语言。运行时才进行检查)<br />Python:info = 130.5 (动态语言。运行时才检查)
<a name="GMYmc"></a>
### 7.5 Java语言中方法重写的本质
1. 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
1. 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验。
- 如果通过则返回这个方法的直接引用,查找过程结束
- 如果不通过,则返回java.lang.IllegalAccessError 异常
3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
3. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
**上面这个过程称为动态分派**
**IllegalAccessError介绍**
1. 程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。
1. 比如,你把应该有的jar包放从工程中拿走了,或者Maven中存在jar包冲突
<a name="JTWY4"></a>
### 7.6 虚方法表
1. 在面向对象的编程中,会很频繁的使用到**动态分派**,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,**JVM采用在类的方法区建立一个虚方法表(virtual method table)来实现**,非虚方法不会出现在表中(因为此类方法已经确定了要如何调用)。使用索引表来代替查找。【上面动态分派的过程,我们可以看到如果子类找不到,还要从下往上找其父类,非常耗时】
1. 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
1. 虚方法表是什么时候被创建的呢?
1. 虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的虚方法表也初始化完毕。
**例子1:**如果类中重写了方法,那么调用的时候,就会直接在该类的虚方法表中查找<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650199852081-b758b47c-f7ab-463e-83b4-008557acc7ee.png#clientId=ue86a20af-6157-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=376&id=u146b9f14&margin=%5Bobject%20Object%5D&name=image.png&originHeight=576&originWidth=891&originalType=binary&ratio=1&rotation=0&showTitle=false&size=366049&status=done&style=none&taskId=uf5413201-f12d-4477-bfce-805b59b988c&title=&width=582)<br />1、比如说son在调用toString的时候,Son没有重写过,Son的父类Father也没有重写过,那就直接调用Object类的toString。那么就直接在虚方法表里指明toString直接指向Object类。<br />2、下次Son对象再调用toString就直接去找Object,不用先找Son-->再找Father-->最后才到Object的这样的一个过程。
**例子2:**虚方法表的举例
```java
interface Friendly {
void sayHello();
void sayGoodbye();
}
class Dog {
public void sayHello() {
}
public String toString() {
return "Dog";
}
}
class Cat implements Friendly {
public void eat() {
}
public void sayHello() {
}
public void sayGoodbye() {
}
protected void finalize() {
}
public String toString(){
return "Cat";
}
}
class CockerSpaniel extends Dog implements Friendly {
public void sayHello() {
super.sayHello();
}
public void sayGoodbye() {
}
}
public class VirtualMethodTable {
}
8、方法返回地址
方法返回地址、动态链接、一些附加信息也叫做帧数据区
- 存放调用该方法的pc寄存器的值(下一条指令的值)
- 一个方法的结束,有两种方式:
- 正常执行完成
- 出现未处理的异常,非正常退出
- 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
- 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
- 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
方法退出的两种方式
当一个方法开始执行后,只有两种方式可以退出这个方法,
(1)正常退出:
- 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;
- 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
- 在字节码指令中,返回指令包含:
- ireturn:当返回值是boolean,byte,char,short和int类型时使用
- lreturn:Long类型
- freturn:Float类型
- dreturn:Double类型
- areturn:引用类型
- return:返回值类型为void的方法、实例初始化方法、类和接口的初始化方法
(2)异常退出:
- 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。
- 方法执行过程中,抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码
异常处理表:
- 反编译字节码文件,可得到 Exception table
- from :字节码指令起始地址
- to :字节码指令结束地址
- target :出现异常跳转至地址为 11 的指令执行
- type :捕获异常的类型
9、一些附加信息
栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。
10、栈相关面试题
10.1 举例栈溢出的情况
SOF(StackOverflowError),栈大小分为固定的,和动态变化。如果是固定的就可能出现StackOverflowError。如果是动态变化的,就会动态扩容,整个内存不足时再去扩容就可能出现OOM
10.2 调整栈大小,就能保证不出现溢出么
不能保证不溢出,只能保证SOF出现的几率小
10.3 分配的栈内存越大越好么
不是,一定时间内降低了OOM概率,但是会挤占其它的线程空间,因为整个虚拟机的内存空间是有限的
10.4 垃圾回收是否涉及到虚拟机栈?:不涉及
位置 | 是否有Error | 是否存在GC |
---|---|---|
程序计数器 | 无 | 不存在 |
虚拟机栈 | 有,SOF | 不存在。直接出栈 |
本地方法栈(在HotSpot的实现中和虚拟机栈一样) | 有 | 不存在 |
堆 | 有,OOM | 存在 |
方法区 | 有 | 存在 |
10.5 方法中定义的局部变量是否线程安全?
具体问题具体分析:
- 如果只有一个线程才可以操作此数据,则必是线程安全的。
- 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题
如果对象是在内部产生,并在内部消亡,没有返回到外部,那么它就是线程安全的,反之则是线程不安全的。
/**
* 面试题:
* 方法中定义的局部变量是否线程安全?具体情况具体分析
*
* 何为线程安全?
* 如果只有一个线程才可以操作此数据,则必是线程安全的。
* 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题。
*/
public class StringBuilderTest {
int num = 10;
//s1的声明方式是线程安全的
public static void method1(){
//StringBuilder:线程不安全
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
//...
}
//sBuilder的操作过程:是线程不安全的
public static void method2(StringBuilder sBuilder){
sBuilder.append("a");
sBuilder.append("b");
//...
}
//s1的操作:是线程不安全的
public static StringBuilder method3(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1;
}
//s1的操作:是线程安全的
public static String method4(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1.toString();
}
public static void main(String[] args) {
StringBuilder s = new StringBuilder();
new Thread(() -> {
s.append("a");
s.append("b");
}).start();
method2(s);
}
}
第六章 本地方法接口
1、本地方法
简单地讲,一个Native Method是一个Java调用非Java代码的接囗。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如C。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern “C”告知C++编译器去调用一个C的函数。
- “A native method is a Java method whose implementation is provided by non-java code.”(本地方法是一个非Java的方法,它的具体实现是非Java代码的实现)
- 在定义一个native method时,并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java语言在外面实现的。
- 本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。
需要注意的是:标识符native可以与其它java标识符连用,但是abstract除外。即native与abstract互斥
public class IHaveNatives {
public native void Native1(int x);
public native static long Native2();
private native synchronized float Native3(Object o);
native void Native4(int[] ary) throws Exception;
}
2、使用Native Method的原因
Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。
2.1 与Java环境外交互:
有时Java应用需要与Java外面的硬件环境交互,这是本地方法存在的主要原因。你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。
2.2 与操作系统的交互:
- JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。
- 然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一底层系统的支持。这些底层系统常常是强大的操作系统。
- 通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用C写的。
- 还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。
2.3 Sun’s Java:
- Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分是用Java实现的,它也通过一些本地方法与外界交互。
- 例如:类java.lang.Thread的setPriority()方法是用Java实现的,但是它实现调用的是该类里的本地方法setPriority0()。这个本地方法是用C实现的,并被植入JVM内部在Windows 95的平台上,这个本地方法最终将调用Win32 setpriority() API。这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVM调用。
3、本地方法的现状
目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等等,不多做介绍。
第七章 本地方法栈
- Java虚拟机栈于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。
- 本地方法栈,也是线程私有的。
- 允许被实现成固定或者是可动态扩展的内存大小(在内存溢出方面和虚拟机栈相同)
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个stackoverflowError 异常。
- 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个outofMemoryError异常。
- 本地方法一般是使用C语言或C++语言实现的。
- 它的具体做法是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库
注意事项:
- 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
- 它甚至可以直接使用本地处理器中的寄存器
- 直接从本地内存的堆中分配任意数量的内存
- 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。
在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。
第八章 堆
1、堆的核心概述
1.1 堆与进程
堆针对一个JVM进程来说是唯一的。也就是一个进程只有一个JVM实例,一个JVM实例中就有一个运行时数据区,一个运行时数据区只有一个堆和一个方法区。
- 但是进程包含多个线程,他们是共享同一堆空间和方法区。每个线程拥有自己的程序计数器、本地方法栈、虚拟机栈
- 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。
- Java堆区在JVM启动的时候即被创建,其空间大小也就确定了,堆是JVM管理的最大一块内存空间,并且堆内存的大小是可以调节的。
- 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
- 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer TLAB)
- 《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)
- 从实际使用角度看:“几乎”所有的对象实例都在堆分配内存,但并非全部。因为还有一些对象是在栈上分配的(逃逸分析,标量替换)
- 数组和对象可能永远不会存储在栈上(不一定),因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
- 在方法结束后(出栈),堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
- 也就是触发了GC的时候,才会进行回收
- 方法结束,频繁出栈,如果堆中对象马上被回收,就会频繁GC,垃圾回收线程就会影响到用户线程,因为有stop the word
堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
public class SimpleHeap {
private int id; // 属性、成员变量
public SimpleHeap(int id) {
this.id = id;
}
public void show() {
System.out.println("My ID is " + id);
}
public static void main(String[] args) {
// new操作会在堆空间中创建对象,并开辟空间,接下来在堆空间中初始化实例变量
SimpleHeap sl = new SimpleHeap(1);
SimpleHeap s2 = new SimpleHeap(2);
int[] arr = new int[10];
Object[] arr1 = new Object[10];
}
}
1.2 堆内存细分
现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:
Java7 及之前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区
- Young Generation Space 新生区 Young/New。又被划分为Eden区和Survivor区
- Old generation space 养老区 Old/Tenure
- Permanent Space 永久区 Perm
- Java 8及之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间
- Young Generation Space 新生区,又被划分为Eden区和Survivor区
- Old generation space 养老区
- Meta Space 元空间 Meta
约定:新生区 <–> 新生代 <–> 年轻代 、 养老区 <–> 老年区 <–> 老年代、 永久区 <–> 永久代
1.3 JVisualVM可视化查看堆内存
public class HeapDemo {
public static void main(String[] args) {
System.out.println("start...");
try {
TimeUnit.MINUTES.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
}
}
1、双击jdk目录下的这个文件
2、工具 -> 插件 -> 安装Visual GC插件
3、运行上面的代码
2、设置堆内存大小与OOM
2.1 设置堆内存
- Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项”-Xms”和”-Xmx”来进行设置。
- -Xms用于表示堆区(年轻代+老年代,不包含元空间)的起始内存,等价于-XX:InitialHeapSize
- -Xmx则用于表示堆区的最大内存,等价于-XX:MaxHeapSize
- 一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutofMemoryError异常
- 通常会将-Xms和-Xmx两个参数配置相同的值
- 原因:假设两个不一样,初始内存小,最大内存大。在运行期间如果堆内存不够用了,会一直扩容直到最大内存。如果内存够用且多了,也会不断的缩容释放。频繁的扩容和释放造成不必要的压力,避免在GC之后调整堆内存给服务器带来压力。
- 如果两个设置一样的就少了频繁扩容和缩容的步骤。内存不够了就直接报OOM
默认情况下:
- 初始内存大小:物理电脑可用内存大小/64
- 最大内存大小:物理电脑可用内存大小/4 ```java /**
- 设置堆空间大小的参数
- -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
- -X 是jvm的运行参数
- ms 是memory start
- -Xmx 用来设置堆空间(年轻代+老年代)的最大内存大小 *
- 默认堆空间的大小
- 初始内存大小:物理电脑内存大小 / 64
- 最大内存大小:物理电脑内存大小 / 4
- 手动设置:-Xms600m -Xmx600m
- 开发中建议将初始堆内存和最大的堆内存设置成相同的值。
- 原因是:若初始内存小于最大内存,就会频繁的扩容和释放内存,会造成不必要的系统压力。
- 设置成相同的值就避免GC后调整堆内存大小,造成系统额外的压力 *
- 查看设置的参数:方式一: jps / jstat -gc 进程id
方式二:-XX:+PrintGCDetails */ public class HeapSpaceInitial { public static void main(String[] args) {
//返回Java虚拟机中的堆内存总量 long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024; //返回Java虚拟机试图使用的最大堆内存量 long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println(“-Xms : “ + initialMemory + “M”); // -Xms : 245M System.out.println(“-Xmx : “ + maxMemory + “M”); // -Xmx : 3630M
System.out.println(“系统内存大小为:” + initialMemory 64.0 / 1024 + “G”); // 15.3125G System.out.println(“系统内存大小为:” + maxMemory 4.0 / 1024 + “G”); // 14.1796875G
try { Thread.sleep(1000000); } catch (InterruptedException e) { e.printStackTrace(); } } } ``` 手动设置参数:-Xms600m -Xmx600m
方式一: jps / jstat -gc 进程id。查看当前程序运行的进程
jps:查看java进程
jstat:查看某进程内存使用情况
SOC:S0区总共容量
S1C:S1区总共容量
S0U:S0区使用的量
S1U:S1区使用的量
EC:伊甸园区总共容量
EU:伊甸园区使用的量
OC:老年代总共容量
OU:老年代使用的量
1、25600+25600+153600+409600 = 614400K
614400 / 1024 = 600M
2、25600+153600+409600 = 588800K
588800 /1024 = 575M
3、并非巧合,S0区和S1区两个只有一个能使用,另一个用不了(后面会详解)
方式二:-XX:+PrintGCDetails
2.2 OOM
```java /**
-Xms600m -Xmx600m */ public class OOMTest {
public static void main(String[] args) {
ArrayList
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
list.add(new Picture(new Random().nextInt(1024 * 1024)));
} } }
class Picture{
private byte[] pixels;
public Picture(int length) {
this.pixels = new byte[length];
}
}
1、设置虚拟机参数:-Xms600m -Xmx600m<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650288809428-593511bb-fd4b-47a9-80b7-a00908ea5830.png#clientId=uce2cf5c0-2ab6-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=159&id=u4fe9444d&margin=%5Bobject%20Object%5D&name=image.png&originHeight=159&originWidth=896&originalType=binary&ratio=1&rotation=0&showTitle=false&size=17350&status=done&style=none&taskId=u83a68840-427c-45e5-988f-2e45033ef4e&title=&width=896)<br />2、堆内存变化图<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650288945161-1fdef00f-5789-42b3-ada2-39e4fb629772.png#clientId=uce2cf5c0-2ab6-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=460&id=ud340eb5b&margin=%5Bobject%20Object%5D&name=image.png&originHeight=892&originWidth=1134&originalType=binary&ratio=1&rotation=0&showTitle=false&size=886578&status=done&style=none&taskId=u7b3f08b9-e96b-497a-a29e-228e0f8f5b9&title=&width=585)<br />3、原因:大对象导致堆内存溢出<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650288986866-55cfef1d-f0c9-4cc6-b073-38c77bc9179c.png#clientId=uce2cf5c0-2ab6-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=227&id=u0ed2344b&margin=%5Bobject%20Object%5D&name=image.png&originHeight=486&originWidth=1375&originalType=binary&ratio=1&rotation=0&showTitle=false&size=354516&status=done&style=none&taskId=u1defa35a-e407-40e4-98d1-0ecc3192119&title=&width=643)
<a name="lw1O8"></a>
## 3、年轻代与老年代
1、存储在JVM中的Java对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致
2、Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)<br />3、其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650289232587-c082f7e7-8b14-44eb-91ac-91cb2756f655.png#clientId=uce2cf5c0-2ab6-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=288&id=u8eafc0f3&margin=%5Bobject%20Object%5D&name=image.png&originHeight=318&originWidth=587&originalType=binary&ratio=1&rotation=0&showTitle=false&size=123230&status=done&style=none&taskId=u8b0f957c-d4a3-46c7-8f53-5ee5936fb50&title=&width=531)<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650289266054-ccccc5cc-c36c-4f58-875e-495112980cb9.png#clientId=uce2cf5c0-2ab6-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=170&id=ud3bce8b9&margin=%5Bobject%20Object%5D&name=image.png&originHeight=260&originWidth=1047&originalType=binary&ratio=1&rotation=0&showTitle=false&size=144199&status=done&style=none&taskId=u7883bcc2-bf54-4819-812a-10aca1f1e03&title=&width=685)<br />配置新生代与老年代在堆结构的占比
- 默认**-XX:NewRatio**=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
- 可以修改**-XX:NewRatio**=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
- 若已知存在较多生命周期长的对象,可以将老年代空间设置大些
![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650289568692-9dd875b6-e171-48de-af9f-9ed64acb4f3e.png#clientId=uce2cf5c0-2ab6-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=272&id=u7786044e&margin=%5Bobject%20Object%5D&name=image.png&originHeight=305&originWidth=608&originalType=binary&ratio=1&rotation=0&showTitle=false&size=61959&status=done&style=none&taskId=uae534ebe-4a8b-4540-817e-2aadbd0524c&title=&width=542)
1. 在HotSpot中,Eden空间和另外两个survivor空间缺省所占的比例是8 : 1 : 1。实际情况是6: 1: 1
![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650289716218-d850f183-9c5e-4dfc-a89f-2b2a301e9569.png#clientId=uce2cf5c0-2ab6-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=307&id=u25f9ec76&margin=%5Bobject%20Object%5D&name=image.png&originHeight=486&originWidth=784&originalType=binary&ratio=1&rotation=0&showTitle=false&size=372303&status=done&style=none&taskId=u1bf5e5e9-5a1e-42a1-abbc-4e37e421d6e&title=&width=496)
2. 当然开发人员可以通过选项**-XX:SurvivorRatio**调整这个空间比例。比如-XX:SurvivorRatio=8
![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650289819619-f2cb9824-a02c-4365-a823-5f9bc59f0173.png#clientId=uce2cf5c0-2ab6-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=120&id=dj22A&margin=%5Bobject%20Object%5D&name=image.png&originHeight=192&originWidth=1038&originalType=binary&ratio=1&rotation=0&showTitle=false&size=149978&status=done&style=none&taskId=u9fe1e215-26c6-40db-9fef-83258ccd35a&title=&width=647)
3. 几乎所有的Java对象都是在Eden区被new出来的。若大对象在Eden存放不下,就直接进入老年代
3. 绝大部分的Java对象的销毁都在新生代进行了(有些大的对象在Eden区无法存储时候,将直接进入老年代),IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。
3. 可以使用选项"-Xmn"设置新生代最大内存大小,但这个参数一般使用默认值就可以了。
![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650289967011-49519d53-8443-4c2b-99ef-1178cf7884c5.png#clientId=uce2cf5c0-2ab6-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=194&id=ub1642546&margin=%5Bobject%20Object%5D&name=image.png&originHeight=303&originWidth=1037&originalType=binary&ratio=1&rotation=0&showTitle=false&size=117026&status=done&style=none&taskId=u8779ddea-1068-4816-9028-66978b4954d&title=&width=663)
```java
/**
* -Xms600m -Xmx600m
*
* -XX:NewRatio : 设置新生代与老年代的比例。默认值是2.
* -XX:SurvivorRatio :设置新生代中Eden区与Survivor区的比例。默认值是8
* -XX:-UseAdaptiveSizePolicy :关闭自适应的内存分配策略(暂时用不到)
* -Xmn:设置新生代的空间的大小。 (一般不设置)
*/
public class EdenSurvivorTest {
public static void main(String[] args) {
System.out.println("我只是来打个酱油~");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
4、图解对象分配过程
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
具体过程:
- new的对象先放伊甸园区。此区有大小限制。
- 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。
- 然后将伊甸园中的剩余对象移动到幸存者0区。
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区
- 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
- 啥时候能去养老区呢?可以设置次数。默认是15次。可以设置新生区进入养老区的年龄限制,设置 JVM 参数:-XX:MaxTenuringThreshold=N 进行设置
- 在养老区,相对悠闲。当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理
若养老区执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。
4.1 图解对象分配(一般情况)
1、我们创建的对象,一般都是存放在Eden区的,当我们Eden区满了后,就会触发GC操作,一般被称为 YGC / Minor GC操作
2、当我们进行一次垃圾收集后,红色的对象将会被回收,而绿色的独享还被占用着,存放在S0(Survivor From)区。同时我们给每个对象设置了一个年龄计数器,经过一次回收后还存在的对象,将其年龄加1。
3、同时Eden区继续存放对象,当Eden区再次存满的时候,又会触发一个MinorGC操作,此时GC将会把 Eden和Survivor From中的对象进行一次垃圾收集,把存活的对象放到 Survivor To(S1)区,同时让存活的对象年龄加1
注意点:当Eden区没满,而From区存满时,不会直接触发MinorGC
下一次再进行GC的时候:
1、这一次的s0区为空,所以成为下一次GC的S1区
2、这一次的s1区则成为下一次GC的S0区
3、也就是说s0区和s1区在互相转换。
4、我们继续不断的进行对象生成和垃圾回收,当Survivor中的对象的年龄达到15的时候,将会触发一次 Promotion 晋升的操作,也就是将年轻代中的对象晋升到老年代中
总结:如果来了一个新对象,先看看 Eden 是否放的下?
- 如果 Eden 放得下,则直接放到 Eden 区
- 如果 Eden 放不下,则触发 YGC,执行垃圾回收,看看还能不能放下?
- 将对象放到老年区又有两种情况:
- 如果 Eden 执行了 YGC 还是无法放不下该对象,那没得办法,只能说明是超大对象,只能直接放到老年代
- 那万一老年代都放不下,则先触发FullGC,再看看能不能放下,放得下最好,但如果还是放不下,那只能报 OOM(当虚拟机不能动态调整内存空间的情况下) ,再不能清理垃圾的情况下再次放入对象,只能报OOM
- 如果 Eden 区满了,将对象往幸存区拷贝时,发现幸存区放不下啦,那只能便宜了某些新对象,让他们直接晋升至老年区
/**
* -Xms600m -Xmx600m
*/
public class HeapInstanceTest {
byte[] buffer = new byte[new Random().nextInt(1024 * 200)];
public static void main(String[] args) {
ArrayList<HeapInstanceTest> list = new ArrayList<HeapInstanceTest>();
while (true) {
list.add(new HeapInstanceTest());
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
4.3 常用调优工具
- JDK命令行
- Eclipse:Memory Analyzer Tool
- Jconsole
- Visual VM(实时监控,推荐)
- Jprofiler(IDEA插件)
- Java Flight Recorder(实时监控)
- GCViewer
-
5、GC分类
我们都知道,JVM的调优的一个环节,也就是垃圾收集,我们需要尽量的避免垃圾回收,因为在垃圾回收的过程中,容易出现STW(Stop the World:垃圾回收线程会暂停用户线程的执行,从而导致程序执行效率低)的问题,而 Major GC 和 Full GC出现STW的时间,是Minor GC的10倍以上
- JVM在进行GC时,并非每次都对上面三个内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(FullGC)
- 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC / Young GC):只是新生代(Eden,s0,s1)的垃圾收集
- 老年代收集(Major GC / Old GC):只是老年代的圾收集。
- 目前,只有CMS GC会有单独收集老年代的行为。
- 很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。目前,只有G1 GC会有这种行为
- 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。
- 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
由于历史原因,外界各种解读,majorGC和Full GC有些混淆。
5.1 Young GC
年轻代 GC(Minor GC)触发机制
- 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden代满。Survivor满不会主动引发GC,在Eden区满的时候,会顺带触发s0区的GC,也就是被动触发GC(每次Minor GC会清理年轻代的内存)
- 因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
- Minor GC会引发STW(Stop The World),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
5.2 Major/Full GC
老年代GC(MajorGC)触发机制:
- 指发生在老年代的GC,对象从老年代消失时,我们说 “Major Gc” 或 “Full GC” 发生了
- 出现了MajorGC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程)
- 也就是在老年代空间不足时,会先尝试触发Minor GC,如果之后空间还不足,则触发Major GC
- Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。
- 如果Major GC后,内存还不足,就报OOM了
Full GC 触发机制(后面细讲)
触发Full GC执行的情况有如下五种:
- 调用System.gc()时,系统建议执行FullGC,但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
说明:Full GC 是开发或调优中尽量要避免的。这样STW时间(暂时时间)会短一些
5.3 GC日志分析
/**
* 测试MinorGC 、 MajorGC、FullGC
* -Xms9m -Xmx9m -XX:+PrintGCDetails
*/
public class GCTest {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "atguigu.com";
while (true) {
list.add(a);
a = a + a;
i++;
}
} catch (Throwable t) {
t.printStackTrace();
System.out.println("遍历次数为:" + i);
}
}
}
- [PSYoungGen: 2037K->504K(2560K)]:年轻代总空间为 2560K ,当前占用 2037K ,经过垃圾回收后剩余504K
2037K->728K(9728K):堆内存总空间为 9728K ,当前占用2037K ,经过垃圾回收后剩余728K
6、堆空间分代思想
为什么要把Java堆分代?不分代就不能正常工作了吗?经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。
- 新生代:有Eden、两块大小相同的survivor(又称为from/to或s0/s1)构成,to总为空。
- 老年代:存放新生代中经历多次GC仍然存活的对象。
其实不分代完全可以,分代的唯一理由就是优化GC性能。
- 如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。(性能低)
- 而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。(多回收新生代,少回收老年代,性能会提高很多)
7、对象内存分配策略(或对象提升(Promotion)规则)
- 如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。
- 对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代
- 对象晋升老年代的年龄阀值,可以通过选项-XX:MaxTenuringThreshold来设置
针对不同年龄段的对象分配原则如下所示:
- 优先分配到Eden:开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发Major GC的次数比 Minor GC要更少,因此可能回收起来就会比较慢
- 大对象直接分配到老年代:尽量避免程序中出现过多的大对象。连续的比较长的内存空间的对象。长的字符串或数组
- 长期存活的对象分配到老年代
- 动态对象年龄判断:如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
- 空间分配担保: -XX:HandlePromotionFailure
/** 测试:大对象直接进入老年代
* -Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails
*/
public class YoungOldAreaTest {
public static void main(String[] args) {
byte[] buffer = new byte[1024 * 1024 * 20];//20m
}
}
8、TLAB为对象分配内存(保证线程安全)
8.1 为什么有 TLAB
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
8.2 什么是 TLAB(Thread Local Allocation Buffer)
- 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
- 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
- 据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。
- 每个线程都有一个TLAB空间
- 当一个线程的TLAB存满时,可以使用公共区域(蓝色)的
8.3 TLAB再说明
- 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
- 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间。
- 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
- 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
- 哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完 了,分配新的缓存区时才需要同步锁定 ——这是《深入理解JVM》—第三版里说的
- 和这里讲的有点不同。我猜测说的意思是某一次分配,如果TLAB用完了,那么这一次先在Eden区直接分配。空闲下来后再加锁分配新的TLAB(TLAB内存较大,分配时间应该较长)
9、堆空间参数设置
9.1 常用参数设置
官方文档:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
/**
* -XX:+PrintFlagsInitial : 查看所有的参数的默认初始值
* -XX:+PrintFlagsFinal :查看所有的参数的最终值(可能会存在修改,不再是初始值)
* 具体查看某个参数的指令: jps:查看当前运行中的进程
* jinfo -flag SurvivorRatio 进程id
*
* -Xms:初始堆空间内存 (默认为物理内存的1/64)
* -Xmx:最大堆空间内存(默认为物理内存的1/4)
* -Xmn:设置新生代的大小 (初始值及最大值)
* -XX:NewRatio:配置新生代与老年代在堆结构的占比(默认是1/2)
* -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例(默认是8:1:1。实际是6:1:1)
* -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
* -XX:+PrintGCDetails:输出详细的GC处理日志
* 打印gc简要信息:① -XX:+PrintGC ② -verbose:gc
* -XX:HandlePromotionFailure:是否设置空间分配担保
*/
public class HeapArgsTest {
public static void main(String[] args) {
}
}
9.2 空间分配担保
- 在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
- 如果大于,则此次Minor GC是安全的
- 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允担保失败。
- 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
- 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
- 如果小于,则进行一次Full GC。
- 如果HandlePromotionFailure=false,则进行一次Full GC。
- 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
- 在JDK6 Update 24(JDK7)之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察openJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。
JDK6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。即 HandlePromotionFailure=true
10、堆不是分配对象的唯一选择
在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:
随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
- 在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术
此外,前面提到的基于OpenJDK深度定制的TaoBao VM,其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。
10.1 逃逸分析概述
如何将堆上的对象分配到栈,需要使用逃逸分析手段。
- 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
- 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
- 逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。可以使用栈上分配
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
逃逸分析举例:
1、没有发生逃逸的对象,则可以分配到栈(无线程安全问题)上,随着方法执行的结束,栈空间就被移除(也就无需GC)
public void my_method() {
V v = new V();
// use v
// ....
v = null;
}
2、下面代码中的 StringBuffer sb 发生了逃逸,不能在栈上分配。StringBuffer对象可以被外部调用
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
3、如果想要StringBuffer sb不发生逃逸,可以这样写。StringBuffer对象只在内部使用,外部无法调用
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
/**
* 逃逸分析
*
* 如何快速的判断是否发生了逃逸分析,大家就看new的对象实体是否有可能在方法外被调用。
*/
public class EscapeAnalysis {
public EscapeAnalysis obj;
/*
* 方法返回EscapeAnalysis对象,发生逃逸
*/
public EscapeAnalysis getInstance(){
return obj == null? new EscapeAnalysis() : obj;
}
/*
* 为成员属性赋值,发生逃逸
*/
public void setObj(){
this.obj = new EscapeAnalysis();
}
//思考:如果当前的obj引用声明为static的?仍然会发生逃逸。
/*
* 对象的作用域仅在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis(){
EscapeAnalysis e = new EscapeAnalysis();
}
/*
* 引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis1(){
EscapeAnalysis e = getInstance();
//getInstance().xxx()同样会发生逃逸
}
}
逃逸分析参数设置:
- 在JDK 1.7版本之后,HotSpot中默认就已经开启了逃逸分析
- 如果使用的是较早的版本,开发人员则可以通过:
- 选项“-XX:+DoEscapeAnalysis”显式开启逃逸分析
- 通过选项“-XX:+PrintEscapeAnalysis”查看逃逸分析的筛选结果
10.2 逃逸分析:代码优化
使用逃逸分析,编译器可以对代码做如下优化:
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配
- 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
(1)栈上分配
JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
常见的栈上分配的场景:在逃逸分析中,已经说明了,分别是给成员变量赋值、方法返回值、实例引用传递。
/**
* 栈上分配测试
* -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
*/
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
// 查看执行时间
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
// 为了方便查看堆内存中对象个数,线程sleep
try {
Thread.sleep(1000000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User(); // 未发生逃逸
}
static class User {
}
}
未开启逃逸分析的情况:
- JVM 参数设置:-Xmx128m -Xms128m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
- 花费的时间为: 114 ms
- 日志打印:发生了 GC ,耗时 114ms
开启逃逸分析的情况:
- JVM 参数设置:-Xmx128m -Xms128m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
- 花费的时间为: 5 ms
- 日志打印:并没有发生 GC(原因是栈上不会发生GC),耗时5ms 。
(2)同步省略(同步消除)
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
- 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
- 如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
例如下面的代码
public void f() {
Object hollis = new Object();
synchronized(hollis) { // 无效锁。 不同线程的hollis对象是不同的。即锁不一样,加锁失败
System.out.println(hollis);
}
}
注意:字节码文件中并没有进行优化,可以看到加锁和释放锁的操作依然存在,同步省略操作是在解释运行时发生的
代码中对hollis这个对象加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉,优化成:
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}
(3)标量替换(分离对象)
- 标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
- 相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
- 在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
```java
public static void main(String args[]) {
alloc(); }
private static void alloc() {
Point point = new Point(1,2);
System.out.println(“point.x” + point.x + “;point.y” + point.y);
}
class Point {
private int x;
private int y;
}
以上代码,经过标量替换后,就会变成
```java
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
}
- 可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。
- 那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。
- 标量替换为栈上分配提供了很好的基础。
标量替换参数设置:
参数 -XX:+ElimilnateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。
/**
* 标量替换测试
* -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
*/
public class ScalarReplace {
public static class User {
public int id;
public String name;
}
public static void alloc() {
User u = new User();//未发生逃逸
u.id = 5;
u.name = "www.atguigu.com";
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
}
}
未开启标量替换
1、JVM 参数:-Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
2、花费的时间为: 99 ms
开启标量替换
1、JVM 参数:-Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
2、日志:时间减少很多,且无GC。花费的时间为: 6 ms
上述代码在主函数中调用了1亿次alloc()方法,进行对象创建由于User对象实例需要占据约16字节的空间,因此累计分配空间达到将近1.5GB。如果堆空间小于这个值,就必然会发生GC。使用如下参数运行上述代码:-server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
这里设置参数如下:
- 参数 -server:启动Server模式,因为在server模式下,才可以启用逃逸分析。
- 参数 -XX:+DoEscapeAnalysis:启用逃逸分析
- 参数 -Xmx10m:指定了堆空间最大为10MB
- 参数 -XX:+PrintGC:将打印GC日志。
参数 -XX:+EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上,比如对象拥有id和name两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配
10.3 逃逸分析的不足
关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟的。
- 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
- 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
- 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
- 注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。据我所知,Oracle Hotspot JVM中并未这么做(刚刚演示的效果,是因为HotSpot实现了标量替换),这一点在逃逸分析相关的文档里已经说明,所以可以明确在HotSpot虚拟机上,所有的对象实例都是创建在堆上。
- 目前很多书籍还是基于JDK7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。
11、小结
- 年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
- 老年代放置长生命周期的对象,通常都是从Survivor区域筛选拷贝过来的Java对象。
- 当然,也有特殊情况,我们知道普通的对象可能会被分配在TLAB上;
- 如果对象较大,无法分配在 TLAB 上,则JVM会试图直接分配在Eden其他位置上;
- 如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代。
- 当GC只发生在年轻代中,回收年轻代对象的行为被称为Minor GC。
- 当GC发生在老年代时则被称为Major GC或者Full GC。
一般的,Minor GC的发生频率要比Major GC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。
第九章 方法区
1、栈、堆、方法区的交互关系
从线程共享与否的角度来看
ThreadLocal:如何保证多个线程在并发环境下的安全性?典型场景就是数据库连接管理和会话管理
栈、堆、方法区的交互关系
下面涉及了对象的访问定位Person 类的 .class 信息存放在方法区中
- person 变量存放在 Java 栈的局部变量表中
- 真正的 person 对象存放在 Java 堆中
- 在 person 对象中,有个指针指向方法区中的 person 类型数据,表明这个 person 对象是用方法区中的 Person 类 new 出来的
2、方法区的理解
官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.4
2.1 方法区的位置
- 《Java虚拟机规范》中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。
- 所以,方法区可以看作是一块独立于Java堆的内存空间。
2.2 方法区的基本理解
方法区主要存放的是 Class,而堆中主要存放的是实例化的对象
- 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。多个线程同时加载统一个类时,只能有一个线程能加载该类,其他线程只能等等待该线程加载完毕,然后直接使用该类,即类只能加载一次。
- 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
- 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutofMemoryError:PermGen space(永久代)或者java.lang.OutOfMemoryError:Metaspace(元空间)
- 加载大量的第三方的jar包
- Tomcat部署的工程过多(30~50个)
- 大量动态的生成反射类(动态代理)
关闭JVM就会释放这个区域的内存。
public class MethodAreaDemo {
public static void main(String[] args) {
System.out.println("start...");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
}
}
2.3 HotSpot方法区演进
在 JDK7 及以前,习惯上把方法区,称为永久代。JDK8开始,使用元空间取代了永久代。我们可以将方法区类比为Java中的接口,将永久代或元空间类比为Java中具体的实现类
- 本质上,方法区和永久代并不等价。仅是对Hotspot而言的可以看作等价。《Java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEAJRockit / IBM J9 中不存在永久代的概念。
- 现在来看,当年使用永久代,不是好的idea。导致Java程序更容易OOM(超过-XX:MaxPermsize上限)
- 而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替
- 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存(GB级别,不易出现OOM)。
- 永久代、元空间二者并不只是名字变了,内部结构也调整了
- 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常
3、设置方法区大小与OOM
3.1 JDK7及以前(永久代)
- 通过-XX:Permsize来设置永久代初始分配空间。默认值是20.75M
- -XX:MaxPermsize来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
- 当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError:PermGen space。
3.2 JDK8及以后(元空间)
- 元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 指定
- 默认值依赖于平台,Windows下,-XX:MetaspaceSize 约为21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。
- 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace
- -XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端 JVM 来说,其默认的 -XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
- 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。-XX:MaxMetaspaceSize使用默认值,使用本地内存空间
3.3 方法区OOM
举例:OOMTest 类继承 ClassLoader 类,获得 defineClass() 方法,可自己进行类的加载 ```java import com.sun.xml.internal.ws.org.objectweb.asm.ClassWriter; import jdk.internal.org.objectweb.asm.Opcodes;
/**
- jdk6/7中:
- -XX:PermSize=10m -XX:MaxPermSize=10m *
- jdk8中:
-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m */ public class OOMTest extends ClassLoader { public static void main(String[] args) {
int j = 0;
try {
OOMTest test = new OOMTest();
for (int i = 0; i < 10000; i++) {
//创建ClassWriter对象,用于生成类的二进制字节码
ClassWriter classWriter = new ClassWriter(0);
//指明版本号,修饰符,类名,包名,父类,接口
classWriter.visit(Opcodes.V1_6, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
//返回byte[]
byte[] code = classWriter.toByteArray();
//类的加载
test.defineClass("Class" + i, code, 0, code.length);//Class对象
j++;
}
} finally {
System.out.println(j);
}
} } ``` 不设置元空间的上限
- 使用默认的 JVM 参数,元空间不设置上限
- 输出结果:10000
设置元空间的上限
- 要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Ec1ipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
- 内存泄漏就是有大量的引用指向某些对象,但是这些对象以后不会使用了,但是因为它们还和GC ROOT有关联,所以导致以后这些对象也不会被回收,这就是内存泄漏的问题
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
4、方法区的内部结构
《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
4.1 类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
- 这个类型的修饰符(public,abstract,final的某个子集)
-
4.2 域(Field)信息
也就是我们常说的成员变量,域信息是比较官方的称呼
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
域的相关信息包括:域名称,域类型,域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
4.3 方法(Method)信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
方法名称
- 方法的返回类型(包括 void 返回类型),void在Java中对应的为void.class
- 方法参数的数量和类型(按顺序)
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
异常表(abstract和native方法除外),异常表记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
public class MethodInnerStrucTest extends Object implements Comparable<String>,Serializable {
// 属性
public int num = 10;
private static String str = "测试方法的内部结构";
// 构造器
// 方法
public void test1(){
int count = 20;
System.out.println("count = " + count);
}
public static int test2(int cal){
int result = 0;
try {
int value = 30;
result = value / cal;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
@Override
public int compareTo(String o) {
return 0;
}
}
```java javap -v -p MethodInnerStrucTest.class > test.txt
反编译字节码文件,并输出值文本文件中,便于查看。 参数 -p 确保能查看 private 权限类型的字段或方法
**类型信息:**<br />在运行时方法区中,类信息中记录了哪个加载器加载了该类,同时类加载器也记录了它加载了哪些类<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650459206208-c9740f97-0ef3-4d40-98a3-21d74b865d59.png#clientId=u2c4cfaca-c9f4-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=103&id=ue6b11cc0&margin=%5Bobject%20Object%5D&name=image.png&originHeight=103&originWidth=805&originalType=binary&ratio=1&rotation=0&showTitle=false&size=10567&status=done&style=none&taskId=ub0f577a4-4168-4cf8-a708-f0136acfa7c&title=&width=805)<br />**域信息:**
1. descriptor: I 表示字段类型为 Integer
1. flags: ACC_PUBLIC 表示字段权限修饰符为 public
![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650459227911-d6ec7a6c-5fbc-4fa9-91f7-fb5ef78dfce1.png#clientId=u2c4cfaca-c9f4-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=211&id=u815777e7&margin=%5Bobject%20Object%5D&name=image.png&originHeight=224&originWidth=432&originalType=binary&ratio=1&rotation=0&showTitle=false&size=11260&status=done&style=none&taskId=u979ab56f-0a49-429f-b806-746f28936cd&title=&width=406)<br />**方法信息:**
1. descriptor: ()V 表示方法返回值类型为 void
1. flags: ACC_PUBLIC 表示方法权限修饰符为 public
1. stack=3 表示操作数栈深度为 3
1. locals=2 表示局部变量个数为 2 个(实力方法包含 this)
1. test1() 方法虽然没有参数,但是其 args_size=1 ,这时因为将 this 作为了参数
![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650459279640-36b65377-ff4d-4a11-a2bf-e944ef36ece6.png#clientId=u2c4cfaca-c9f4-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=460&id=u49db30ef&margin=%5Bobject%20Object%5D&name=image.png&originHeight=702&originWidth=874&originalType=binary&ratio=1&rotation=0&showTitle=false&size=46259&status=done&style=none&taskId=u72ac339b-08b3-4e4e-b74b-fde83b89487&title=&width=573)
<a name="TCyQj"></a>
### 4.3 non-final类型的类变量
1. 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
1. 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它
**举例:**
1. 如下代码所示,即使我们把order设置为null,也不会出现空指针异常
1. 这更加表明了 static 类型的字段和方法随着类的加载而加载,并不属于特定的类实例
```java
/**
* non-final的类变量
*/
public class MethodAreaTest {
public static void main(String[] args) {
Order order = null;
order.hello();
System.out.println(order.count);
}
}
class Order {
public static int count = 1;
public static final int number = 2;
public static void hello() {
System.out.println("hello!");
}
}
全局常量:static final
- 全局常量就是使用 static final 进行修饰
- 被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。
可以发现 staitc和final同时修饰的number 的值在编译的时候已经写死在字节码文件中了。
4.5 运行时常量池
官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html
- 方法区,内部包含了运行时常量池
- 字节码文件,内部包含了常量池。(之前的字节码文件中已经看到了很多Constant pool的东西,这个就是常量池)
- 要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。
要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。
一、常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外。还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用。
- 字面量: 10 , “我是某某”这种数字和字符串都是字面量
为什么需要常量池?
一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池。这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍
public class SimpleClass {
public void sayHello() {
System.out.println("hello");
}
}
虽然上述代码只有194字节,但是里面却使用了String、System、PrintStream及Object等结构。
- 比如说我们这个文件中有6个地方用到了”hello”这个字符串,如果不用常量池,就需要在6个地方全写一遍,造成臃肿。我们可以将”hello”等所需用到的结构信息记录在常量池中,并通过引用的方式,来加载、调用所需的结构
- 这里的代码量其实很少了,如果代码多的话,引用的结构将会更多,这里就需要用到常量池了。
常量池中有啥?
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
1、#3,#5等等这些带# 的,都是引用了常量池。
常量池总结:常量池、可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
二、运行时常量池
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。
- 常量池表(Constant Pool Table)是Class字节码文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。(运行时常量池就是常量池在程序运行时的称呼)
- 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
- JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
- 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
- 运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。(可能比常量池多)
- 运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutofMemoryError异常。
5、方法区的使用举例
public class MethodAreaDemo {
public static void main(String[] args) {
int x = 500;
int y = 100;
int a = x / y;
int b = 50;
System.out.println(a + b);
}
}
Classfile /D:/workspace_idea5/JVMDemo/out/production/chapter09/com/atguigu/java1/MethodAreaDemo.class
Last modified 2020-4-23; size 640 bytes
MD5 checksum a2e8a0e034e2dd986b45d36a3401a63b
Compiled from "MethodAreaDemo.java"
public class com.atguigu.java1.MethodAreaDemo // 类的说明
minor version: 0
major version: 51
flags: ACC_PUBLIC, ACC_SUPER // 类的声明和权限
Constant pool: // 常量池
#1 = Methodref #5.#24 // java/lang/Object."<init>":()V
#2 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #27.#28 // java/io/PrintStream.println:(I)V
#4 = Class #29 // com/atguigu/java1/MethodAreaDemo
#5 = Class #30 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 LocalVariableTable
#11 = Utf8 this
#12 = Utf8 Lcom/atguigu/java1/MethodAreaDemo;
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 args
#16 = Utf8 [Ljava/lang/String;
#17 = Utf8 x
#18 = Utf8 I
#19 = Utf8 y
#20 = Utf8 a
#21 = Utf8 b
#22 = Utf8 SourceFile
#23 = Utf8 MethodAreaDemo.java
#24 = NameAndType #6:#7 // "<init>":()V
#25 = Class #31 // java/lang/System
#26 = NameAndType #32:#33 // out:Ljava/io/PrintStream;
#27 = Class #34 // java/io/PrintStream
#28 = NameAndType #35:#36 // println:(I)V
#29 = Utf8 com/atguigu/java1/MethodAreaDemo
#30 = Utf8 java/lang/Object
#31 = Utf8 java/lang/System
#32 = Utf8 out
#33 = Utf8 Ljava/io/PrintStream;
#34 = Utf8 java/io/PrintStream
#35 = Utf8 println
#36 = Utf8 (I)V
{
public com.atguigu.java1.MethodAreaDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 7: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/atguigu/java1/MethodAreaDemo;
public static void main(java.lang.String[]); // main方法参数类型
descriptor: ([Ljava/lang/String;)V // V表示void
flags: ACC_PUBLIC, ACC_STATIC // mian方法的修饰符
Code:
stack=3, locals=5, args_size=1 // 操作数栈深度:3,局部变量表长度:5,main方法的形参为1
// 第一列为程序计数器保存的指令地址:
0: sipush 500
3: istore_1
4: bipush 100
6: istore_2
7: iload_1
8: iload_2
9: idiv
10: istore_3
11: bipush 50
13: istore 4
15: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
18: iload_3
19: iload 4
21: iadd
22: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
25: return
LineNumberTable: // 指令地址与真实代码行号的对应关系
line 9: 0
line 10: 4
line 11: 7
line 12: 11
line 13: 15
line 14: 25
LocalVariableTable: // 局部变量表
Start Length Slot Name Signature
0 26 0 args [Ljava/lang/String;
4 22 1 x I
7 19 2 y I
11 15 3 a I
15 11 4 b I
}
SourceFile: "MethodAreaDemo.java"
图解字节码指令执行流程:
1、初始状态
2、首先将操作数500压入操作数栈中
3、然后操作数 500 从操作数栈中取出,存储到局部变量表中索引为 1 的位置
4、
5、
6、
7、
8、
9、
10、
11、图片写错了是#25和#26(获得System类)
12、
13、
15、执行加法运算后,将计算结果放在操作数栈顶
16、就是真正的打印
17、
符号引用 —> 直接饮用上面代码调用 System.out.println() 方法时,首先需要看看 System 类有没有加载,再看看 PrintStream 类有没有加载
如果没有加载,则执行加载,执行时,将常量池中的符号引用(字面量)转换为运行时常量池的直接引用(真正的地址值)
6、方法区演进细节
6.1 永久代演进过程
首先明确:只有Hotspot才有永久代。BEA JRockit、IBMJ9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一
- Hotspot中方法区的变化: | JDK1.6及以前 | 有永久代(permanent generation),静态变量存储在永久代上 | | —- | —- | | JDK1.7 | 有永久代,但已经逐步 “去永久代”,字符串常量池,静态变量移除,保存在堆中 | | JDK1.8 | 无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池、静态变量仍然在堆中。 |
JDK6:方法区由永久代实现,使用 JVM 虚拟机内存(虚拟的内存)
JDK7:方法区由永久代实现,使用 JVM 虚拟机内存
JDK8:方法区由元空间实现,使用物理机本地内存
6.2 永久代为什么要被元空间替代
官方文档:http://openjdk.java.net/jeps/122
- 随着Java8的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)。
- 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。
这项改动是很有必要的,原因有:
- 为永久代设置空间大小是很难确定的。
- 在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。Exception in thread ‘dubbo client x.x connector’ java.lang.OutOfMemoryError:PermGen space。
- 而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。 因此,默认情况下,元空间的大小仅受本地内存限制。
- 对永久代进行调优是很困难的。方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再用的类型,方法区的调优主要是为了降低Full GC
- 有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。
- 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻**。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。
6.3 字符串常量池
字符串常量池 StringTable 为什么要调整位置?:从方法区调整到堆空间
- JDK7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在Full GC的时候才会执行永久代的垃圾回收,而Full GC是老年代的空间不足、永久代不足时才会触发。这就导致StringTable回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
6.4 静态变量放在哪里
一、对象实体在存放位置
对象实体始终都存在堆空间 ```java /**
- 结论:
- 1、静态引用对应的对象实体始终都存在堆空间
- 也就是这个new byte[1024 1024 100]始终都存在堆空间
- 2、只是那个变量(相当于下面的arr变量名)在JDK6、JDK7、JDK8存放位置中有所变化 JDK6:堆空间 JDK7:堆空间 JDK8:堆空间 *
- jdk7:
-Xms200m -Xmx200m -XX:PermSize=300m -XX:MaxPermSize=300m -XX:+PrintGCDetails
jdk 8:
-Xms200m -Xmx200m -XX:MetaspaceSize=300m -XX:MaxMetaspaceSize=300m -XX:+PrintGCDetails / public class StaticFieldTest { private static byte[] arr = new byte[1024 1024 * 100]; // 100MB
public static void main(String[] args) { System.out.println(StaticFieldTest.arr);
- 为永久代设置空间大小是很难确定的。
// try { // Thread.sleep(1000000); // } catch (InterruptedException e) { // e.printStackTrace(); // } } }
JDK6环境:存放到老年代(堆空间)<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650205905279-b4667cb1-071c-4484-889f-9cc0ce40426f.png#clientId=u910e6b5e-18eb-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=226&id=u212a1ff0&margin=%5Bobject%20Object%5D&name=image.png&originHeight=501&originWidth=1567&originalType=url&ratio=1&rotation=0&showTitle=false&size=615094&status=done&style=none&taskId=u00736f4b-1fa9-41bc-825d-bc1707b6feb&title=&width=707)<br />JDK7环境:存放到老年代(堆空间)<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650205905191-0be907d2-1673-4475-aae1-86a497a5e4a7.png#clientId=u910e6b5e-18eb-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=198&id=u48c6ee93&margin=%5Bobject%20Object%5D&name=image.png&originHeight=484&originWidth=1720&originalType=url&ratio=1&rotation=0&showTitle=false&size=609563&status=done&style=none&taskId=uf1b4ac6a-4797-4a1d-8f8c-27c94ac37f6&title=&width=702)<br />JDK8环境:存放到老年代(堆空间)<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650205905406-627d94a3-eb52-4d22-a7fe-765eef68406d.png#clientId=u910e6b5e-18eb-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=246&id=u6596c9bb&margin=%5Bobject%20Object%5D&name=image.png&originHeight=538&originWidth=1545&originalType=url&ratio=1&rotation=0&showTitle=false&size=811955&status=done&style=none&taskId=uae131abe-1e29-403f-b91f-4bc54d069bc&title=&width=706)
<a name="YGvQ3"></a>
#### 二、变量(名)存放位置
这个问题需要用JHSDB工具来进行分析,这个工具是JDK9开始自带的,在bin目录下可以找到
```java
/**
* 《深入理解Java虚拟机》中的案例:
* staticObj、instanceObj、localObj存放在哪里?
*/
public class StaticObjTest {
static class Test {
// staticObj随着Test的类型信息存放在方法区
static ObjectHolder staticObj = new ObjectHolder();
//
ObjectHolder instanceObj = new ObjectHolder();
void foo() {
ObjectHolder localObj = new ObjectHolder();
System.out.println("done");
}
}
private static class ObjectHolder {
}
public static void main(String[] args) {
Test test = new StaticObjTest.Test();
test.foo();
}
}
JDK6环境下:
- staticObj随着Test的类型信息存放在方法区
- instanceObj随着Test的对象实例存放在Java堆
- localObject则是存放在foo()方法栈帧的局部变量表中。
- 测试发现:三个对象的数据在内存中的地址都落在Eden区范围内,所以结论:只要是对象实例必然会在Java堆中分配。
1、0x00007f32c7800000(Eden区的起始地址) —— 0x00007f32c7b50000(Eden区的终止地址)
2、可以发现三个变量都在这个范围内。所有这三个new ObjectHolder()都存放在堆空间中
接着,找到了一个引用该staticObj对象的地方,是在一个java.lang.Class的实例里,并且给出了这个实例的地址,通过Inspector查看该对象实例,可以清楚看到这确实是一个java.lang.Class类型的对象实例,里面有一个名为staticobj的实例字段:
从《Java虚拟机规范》所定义的概念模型来看,所有Class相关的信息都应该存放在方法区之中,但方法区该如何实现,《Java虚拟机规范》并未做出规定,这就成了一件允许不同虚拟机自己灵活把握的事情。JDK7及其以后版本的HotSpot虚拟机选择把静态变量与类型在Java语言一端的映射Class对象存放在一起,存储于Java堆之中,从我们的实验中也明确验证了这一点
7、方法区的垃圾回收
- 有些人认为方法区(如Hotspot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。
- 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。
- 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
- 先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
- HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
- 回收废弃常量与回收Java堆中的对象非常类似。(关于常量的回收比较简单,重点是类的回收)
下面也称作类卸载
- 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
- Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class 以及 -XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
8、运行时数据区总结
常见面试题
百度
- 三面:说一下JVM内存模型吧,有哪些区?分别干什么的?
- 蚂蚁金服:
- Java8的内存分代改进
- JVM内存分哪几个区,每个区的作用是什么?
- 一面:JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区?
- 二面:Eden和survior的比例分配
- 小米:
- jvm内存分区,为什么要有新生代和老年代
- 字节跳动:
- 二面:Java的内存分区
- 二面:讲讲vm运行时数据库区
- 什么时候对象会进入老年代?
- 京东:
- JVM的内存结构,Eden和Survivor比例。
- JVM内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为Eden和survivor。
- 天猫:
- 一面:Jvm内存模型以及分区,需要详细到每个区放什么。
- 一面:JVM的内存模型,Java8做了什么改
- 拼多多:
- JVM内存分哪几个区,每个区的作用是什么?
美团:
1、对象的实例化
1.1 对象创建的方式
- new:最常见的方式、单例类中调用getInstance的静态类方法,XXXFactory的静态方法
- Class的newInstance方法:在JDK9里面被标记为过时的方法,因为只能调用空参构造器,并且权限必须为 public
- Constructor的newInstance(Xxxx):反射的方式,可以调用空参的,或者带参的构造器
- 使用clone():不调用任何的构造器,要求当前的类需要实现Cloneable接口中的clone方法
- 使用序列化:从文件中,从网络中获取一个对象的二进制流,序列化一般用于Socket的网络传输
-
1.2 对象创建的步骤
public class ObjectTest {
public static void main(String[] args) {
Object obj = new Object();
}
}
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1
8: return
LineNumberTable:
line 9: 0
line 10: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
8 1 1 obj Ljava/lang/Object;
}
1、判断对象对应的类是否加载、链接、初始化
虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化。(即判断类元信息是否存在)。
- 如果该类没有加载,那么在双亲委派模式下,使用当前类加载器以ClassLoader + 包名 + 类名为key进行查找对应的.class文件,如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class对象。
2、为对象分配内存
- 首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小
- 如果内存规整:采用指针碰撞分配内存
- 如果内存是规整的,那么虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存
- 意思是所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针往空闲内存那边挪动一段与对象大小相等的距离罢了。
- 如果垃圾收集器选择的是Serial ,ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带Compact(整理)过程的收集器时,使用指针碰撞。
- 标记压缩(整理)算法会整理内存碎片,堆内存一存对象,另一边为空闲区域
- 如果内存不规整
- 如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。
- 意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为了 “空闲列表(Free List)”
- 选择哪种分配方式由Java堆是否规整所决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定
- 标记清除算法清理过后的堆内存,就会存在很多内存碎片。
3、处理并发问题
- 采用CAS+失败重试保证更新的原子性
- 每个线程预先分配TLAB - 通过设置 -XX:+UseTLAB参数来设置(区域加锁机制)
- 在Eden区给每个线程分配一块区域
4、初始化分配到的空间
- 所有属性设置默认值,保证对象实例字段在不赋值可以直接使用
- 给对象属性赋值的顺序:
- 属性的默认值初始化
- 显示初始化/代码块初始化(并列关系,谁先谁后看代码编写的顺序)
- 构造器初始化
5、设置对象的对象头
- 将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。
6、执行init方法进行初始化
- 在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量
因此一般来说(由字节码中跟随invokespecial指令所决定),new指令之后会接着就是执行init方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。 ```java /**
- 测试对象实例化的过程:
- ① 加载类元信息 - ② 为对象分配内存 - ③ 处理并发问题 - ④ 属性的默认初始化(零值初始化)
- ⑤ 设置对象头的信息 - ⑥ 属性的显式初始化、代码块中初始化、构造器中初始化
- 给对象的属性赋值的操作:
- ① 属性的默认初始化 - ② 显式初始化 / ③ 代码块中初始化 - ④ 构造器中初始化 */ public class Customer{ int id = 1001; String name; Account acct;
{ name = “匿名客户”; } public Customer(){ acct = new Account(); } }
class Account{
}
![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650549651278-29cac383-bdb5-4eef-ad8d-6c5d17bed4b1.png#clientId=ue14d32b0-d4d3-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=561&id=u89a98bc0&margin=%5Bobject%20Object%5D&name=image.png&originHeight=561&originWidth=1606&originalType=binary&ratio=1&rotation=0&showTitle=false&size=88790&status=done&style=none&taskId=u0c6178d3-0bb4-4e6b-b6bf-574600c7758&title=&width=1606)<br />init() 方法的字节码指令:
- 属性的默认值初始化:id = 1001;
- 显示初始化/代码块初始化:name = "匿名客户";
- 构造器初始化:acct = new Account();
<a name="GlL8A"></a>
## 2、对象的内存布局
![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650549895721-314e45da-ad04-4514-9490-da5f24ce6ee4.png#clientId=ue14d32b0-d4d3-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=833&id=u3d2f6aad&margin=%5Bobject%20Object%5D&name=image.png&originHeight=833&originWidth=1816&originalType=binary&ratio=1&rotation=0&showTitle=false&size=297649&status=done&style=none&taskId=ubace8f84-4050-41f5-bde0-92729bf5795&title=&width=1816)
```java
public class Customer{
int id = 1001;
String name;
Account acct;
{
name = "匿名客户";
}
public Customer(){
acct = new Account();
}
}
class Account{
}
public class CustomerTest {
public static void main(String[] args) {
Customer cust = new Customer();
}
}
3、对象的访问定位
JVM是如何通过栈帧中的对象引用访问到其内部的对象实例呢?
对象的两种访问方式:句柄访问和直接指针
1、句柄访问
- 缺点:在堆空间中开辟了一块空间作为句柄池,句柄池本身也会占用空间;通过两次指针访问才能访问到堆中的对象,效率低
- 优点:reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改
2、直接指针(HotSpot采用)
- 优点:直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据
- 缺点:对象被移动(垃圾收集时移动对象很普遍)时需要修改 reference 的值
第十一章 直接(本地)内存
1、直接内存概述
- 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
- 直接内存是在Java堆外的、直接向系统申请的内存区间。
- 来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
通常,访问直接内存的速度会优于Java堆。即读写性能高。
- 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
- Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
```java
/**
- IO NIO (New IO / Non-Blocking IO)
- byte[] / char[] Buffer
- Stream Channel *
- 查看直接内存的占用与释放 / public class BufferTest { private static final int BUFFER = 1024 1024 * 1024;// 1GB
public static void main(String[] args){ //直接分配本地内存空间 ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER); System.out.println(“直接内存分配完毕,请求指示!”);
Scanner scanner = new Scanner(System.in); scanner.next();
System.out.println(“直接内存开始释放!”); byteBuffer = null; System.gc(); scanner.next(); } }
**直接占用了 1G 的本地内存**<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650551242817-59460621-d34c-48a4-96a4-49a376cdfaf2.png#clientId=ue14d32b0-d4d3-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=480&id=u7c838a8e&margin=%5Bobject%20Object%5D&name=image.png&originHeight=480&originWidth=1326&originalType=binary&ratio=1&rotation=0&showTitle=false&size=433756&status=done&style=none&taskId=ucc40f054-677b-4965-8aa5-b4bc88d1c19&title=&width=1326)
<a name="qDLSq"></a>
### 2、BIO 与 NIO
**非直接缓存区(BIO):**原来采用BIO的架构,在读写本地文件时,我们需要从用户态切换成内核态<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650552104804-16f099df-b820-4dec-889a-728314505a30.png#clientId=ue14d32b0-d4d3-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=300&id=u980229b2&margin=%5Bobject%20Object%5D&name=image.png&originHeight=493&originWidth=1160&originalType=binary&ratio=1&rotation=0&showTitle=false&size=251688&status=done&style=none&taskId=ud49ed960-09f3-4ca0-b3ff-ee11645ba4a&title=&width=705)<br />**直接缓冲区(NIO):**NIO直接操作物理磁盘,省去了中间过程<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22523384/1650552130291-e457627d-858d-4335-b092-3280022d7d79.png#clientId=ue14d32b0-d4d3-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=368&id=u1e94a674&margin=%5Bobject%20Object%5D&name=image.png&originHeight=608&originWidth=1168&originalType=binary&ratio=1&rotation=0&showTitle=false&size=293838&status=done&style=none&taskId=uff143e62-cef4-467c-b217-c328ceb8ef5&title=&width=707)
```java
public class BufferTest1 {
private static final String TO = "F:\\test\\异界BD中字.mp4";
private static final int _100Mb = 1024 * 1024 * 100; // 缓冲区大小
public static void main(String[] args) {
long sum = 0;
String src = "F:\\test\\异界BD中字.mp4";
for (int i = 0; i < 3; i++) {
String dest = "F:\\test\\异界BD中字_" + i + ".mp4";
// sum += io(src,dest); // 传统方式复制三次:花费54606ms
sum += directBuffer(src,dest); // 采用本地字节缓冲区复制三次:花费50244ms。比直接使用IO时间端,文件越大、效果越明显
}
System.out.println("总花费的时间为:" + sum );
}
private static long directBuffer(String src,String dest) {
long start = System.currentTimeMillis();
FileChannel inChannel = null;
FileChannel outChannel = null;
try {
inChannel = new FileInputStream(src).getChannel();
outChannel = new FileOutputStream(dest).getChannel();
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
while (inChannel.read(byteBuffer) != -1) {
byteBuffer.flip();//修改为读数据模式
outChannel.write(byteBuffer);
byteBuffer.clear();//清空
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (inChannel != null) {
try {
inChannel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (outChannel != null) {
try {
outChannel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
long end = System.currentTimeMillis();
return end - start;
}
private static long io(String src,String dest) {
long start = System.currentTimeMillis();
FileInputStream fis = null;
FileOutputStream fos = null;
try {
fis = new FileInputStream(src);
fos = new FileOutputStream(dest);
byte[] buffer = new byte[_100Mb];
while (true) {
int len = fis.read(buffer);
if (len == -1) {
break;
}
fos.write(buffer, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fis != null) {
try {
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
long end = System.currentTimeMillis();
return end - start;
}
}
3、直接内存与 OOM
- 直接内存也可能导致OutofMemoryError异常
- 由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。
- 直接内存的缺点为:
- 分配回收成本较高
- 不受JVM内存回收管理
- 直接内存大小可以通过MaxDirectMemorySize设置
如果不指定,默认与堆的最大值-Xmx参数值一致
/**
* 本地内存的OOM: OutOfMemoryError: Direct buffer memory
*/
public class BufferTest2 {
private static final int BUFFER = 1024 * 1024 * 20;//20MB
public static void main(String[] args) {
ArrayList<ByteBuffer> list = new ArrayList<>();
int count = 0;
try {
while(true){
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
list.add(byteBuffer);
count++;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally {
System.out.println(count);
}
}
}