【学习笔记】深入理解JVM之运行时数据区
首发地址:地址 (25w字学习笔记,记录众多知识点,欢迎大家前来阅读!)
本篇参考:
《深入理解JAVA虚拟机》第三版
第二章、第八章
尚硅谷JVM
39 - 101 集
1、概述
之前我们了解了 JVM
的一个大体层级:
在上篇文章中,我们也提及到了 类加载器
的一个大致流程(文章地址)。而今天我们来看的就是 运行时数据区
这个部分。
♣️ 那什么是运行时数据区呢?
Java
虚拟机在执行 Java
程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁的。
而 Java
虚拟机包含一下几个运行时数据区域:
- 方法区
- 程序计数器
- 堆
- 本地方法栈
- 虚拟机栈
注意:方法区、堆是所有线程共享的数据区。其他则是线程隔离!
如下图:
2、程序计数器(PC)
♣️ 什么是程序计数器呢?
程序计数器(Program Counter Register)
是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
在 Java
虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来 选取下一条需要执行的字节码指令 ,它是程序控制流的指示器,分支、循环、跳转、异常处 理、线程恢复等基础功能都需要依赖这个计数器来完成。
如图:
执行引擎会从 PC
中取出指令地址,然后去操作相关的局部变量表和数栈并通过机器指令传达给 CPU
。
那么我们记下来通过两个问题来对 PC
寄存器进行更深入的理解。
♣️ 使用PC寄存器存储字节码指令地址有什么用呢?
♣️ 为什么使用PC寄存器记录当前线程的执行地址呢?
答:
因为 CPU
需要不停的切换各个线程,这个时候切换回来了以后,就得知道接着从哪开始继续执行。所以就需要 PC
来记录当前的字节码地址。
JVM
的字节码解释器就需要通过改变 PC
寄存器的值来明确下一条应该执行什么样的字节码指令。
3、虚拟机栈(VMS)
3.1 概述
与 PC
一样,VMS
也是线程私有的,它的生命周期和线程相同。虚拟机栈描述的是 Java
方法执行的线程内存模型:每个方法被执行的时候,Java
虚拟机都会同步创建一个 栈帧(Stack Frame)
用于 存储局部变量表(8中基本数据类型、对象的引用地址)、操作数栈、动态链接、方法出口等信息。 每一个方法被调用直到执行完毕的过程,就对应着一个个栈帧在 虚拟机栈(VMS)
中从入栈到出栈的过程。
优点:
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
JVM
直接对Java
栈的操作只有两个:- 每个方法执行,伴随着进栈(入栈、压栈)
- 执行结束后的出栈工作
- 对于栈来说不存在垃圾回收问题
- GC(不需要) ;OOM(存在)
🌹 栈中可能出现的异常
Java
虚拟机规范允许Java 栈的大小是动态的或者是不变的。
- 如果采用固定大小的
Java
虚拟机栈,那每一个线程的Java
虚拟机栈容量可以在线程创建的时候独立选定。如果是线程请求分配的栈容量超过Java
虚拟机栈允许的最大容量,Java
虚拟机将会抛出一个StackOverflowError
。 - 如果
Java
虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程的时候没有足够的内存去创建对应的虚拟机栈,那Java
虚拟机可能会抛出一个OutOfMemoryError
异常。
- 如果采用固定大小的
例如:
public class StackOverflow {private static int count = 1;public static void main(String[] args) {System.out.println(count);count ++;main(args);}
}
//自己调用自己
3.2 栈的存储单位
-
每个线程都有自己的栈,栈中的数据都是以 栈帧为基本单位 。
-
在这个线程上正在执行的每个方法都各自对应一个 栈帧 。
-
栈帧 是一个内存区块,是一个数据集,
维系着方法执行过程中的各种数据信息
。 -
不同线程中所包含的栈帧是不允许相互引用的,即不可能在一个 栈帧 之中引用另外一个线程的 栈帧 。
-
如果当前方法调用了其他方法,方法返回之际,当前
栈帧
会传回此方法的执行结果给前一个 栈帧 ,接着,虚拟机会丢弃当前 栈帧 ,使得前一个 栈帧 重新成为当前 栈帧 。 -
Java
方法有两种返回函数的方式,一种是正常的函数返回,使用return
指令;另外一种是抛出异常。不管使用哪种方式,都会导致 栈帧 被弹出。
🌹 栈帧的内部结构:
- 局部变量表
- 操作数栈(或表达式栈)
- 动态链接(或指向运行时常量池的方法应用)
- 方法返回地址(或方法正常退出或者异常退出的定义)
- 一些附加信息
♣️ 3.2.1 局部变量表
首先我们需要知道什么是局部变量表 ?
局部变量表
也被称为局部变量数组或本地变量表
。- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量 ,这些数据类型包括各类基本数据类型、对象引用(reference),以及
returnAddress
类型。 - 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此 并不存在安全问题 。
- 局部变量表所需要的大小是在编译期间确定下来的 ,并保存在方法的
Code
属性的maximun local variables
数据项中。在方法运行期间是不会改变局部变量表的大小的。 - 局部变量表中的变量值在当前方法中调用中有效。 在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。 当方法调用结束后随着方法栈帧的销毁,局部变量表也会随之销毁。
- 最基本的存储单元是
Slot(变量槽)
,在局部变量表中,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。
🌲 其他知识补充:
变量的分类:
- 按照数据类型分:
- 基本数据类型
- 引用数据类型
- 按照类中声明的位置来分
- 成员变量:使用前,都经历过默认初始化赋值。
- 类变量:链接阶段 的准备阶段:会给类变量赋零值。
- 实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值。
- 局部变量:在使用前必须要进行显式赋值!否则不通过编译!
- 成员变量:使用前,都经历过默认初始化赋值。
♣️ 3.2.2 操作数栈(Operand Stack)
操作数栈也常被称为操作栈,在方法执行过程中通过 字节码 指令 ,往栈中写入数据或提取数据,即 入栈(push)/出栈(pop)
。
常见的作用:
- 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈
就是JVM
执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建进来,这个方法的操作数栈是空的。- 栈中的任意一个元素都是
Java
的数据类型。 Java
虚拟机的解释执行引擎为称为基于栈的执行引擎
,里面的栈
就是操作数栈
。
⚠️
操作数栈中的元素数据类型必须是与字节码指令的序列严格匹配(字节码验证),在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。
🌲 引入问题:i ++ 和 ++ i 的区别?
我们都知道:
i++ 后加加,原理:先自增,然后返回自增之前的值
++i 前加加,原理:先自增,然后返回自增之后的值
今天我们就可以通过操作数栈来解释这个问题
例如:
/*** 示例代码*/
public class demo {public void test1() {int i = 0;int j = i++;}public void test2() {int i = 0;int j = ++i;}public void test3() {int i = 0;int j = i++ + i++;}
}
通过 javac demo.java 进行编译,得到 demo.class 字节码文件。
再通过 javap -c demo.class 进行反编译,得到反编译代码如下:
public class demo {public demo();Code:0: aload_01: invokespecial #1 // Method java/lang/Object."<init>":()V4: return// iload 加载到操作栈中// istore存储到局部变量表中// iinc 增加public void test1();Code:0: iconst_0 // 入栈常数01: istore_1 // 赋值1号存储单元为常数0(i = 0)2: iload_1 // 加载1号存储单元值到寄存器(记为stack[0] = 0)3: iinc 1, 1 // 递增1号存储单元值(i = i + 1 = 0 + 1 = 1)6: istore_2 // 赋值2号存储单元为寄存器值(j = stack[0] = 0)7: return // 返回(此时:i = 1,j = 0)public void test2();Code:0: iconst_0 // 入栈常数01: istore_1 // 赋值1号存储单元为常数0(i = 0)2: iinc 1, 1 // 递增1号存储单元值(i = i + 1 = 0 + 1 = 1)5: iload_1 // 加载1号存储单元值到寄存器(记为stack[0] = 1)6: istore_2 // 赋值2号存储单元为寄存器值(j = stack[0] = 1)7: return // 返回(此时:i = 1,j = 1)public void test3();Code:0: iconst_0 // 入栈常数01: istore_1 // 赋值1号存储单元为常数0(i = 0)2: iload_1 // 加载1号存储单元值到寄存器(记为stack[0] = 0)3: iinc 1, 1 // 递增1号存储单元值(i = i + 1 = 0 + 1 = 1)6: iload_1 // 加载1号存储单元值到寄存器(记为stack[1] = 1)7: iinc 1, 1 // 递增1号存储单元值(i = i + 1 = 1 + 1 = 2)10: iadd // 取寄存器值并执行相加操作(stack[0] = stack[0] + stack[1] = 0 + 1 = 1)11: istore_2 // 赋值2号存储单元为寄存器值(j = stack[0] = 1)12: return // 返回(此时:i = 2,j = 1)
}
过程:
i ++
:- 首先先把
1
入栈顶iconst_0
。 - 然后存储到 局部变量表中
istore_1
。 - 接着在加载到 操作栈 中
iload_1
。 - 进行递增
iinc
。 - 再进行存储到 局部变量表 中
istore_2
。 - 返回:i = 1,j = 0。
- 首先先把
++ i
:- 首先先把
1
入栈顶iconst_0
。 - 然后存储到 局部变量表中
istore_1
。 - 进行递增
iinc
。 - 接着在加载到 操作栈 中
iload_1
。 - 再进行存储到 局部变量表 中
istore_2
。 - 返回:i = 1,j = 1。
通过上述反编译内容可知,i++
和 ++i
操作是在加载 i
值至寄存器步骤的前后执行的,而赋值语句右侧的表达式计算时,是根据加载到寄存器的值进行计算的。
具体也可以参考本篇文章:地址
♣️ 3.2.3 动态链接
每个 栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的 动态链接
。
我们知道 Class
文件的常量池中存 有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号 引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为 动态连接
。
♣️ 3.2.4 方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法:
- 第一种方式是执行引擎遇到任意一个方法
返回的字节码指令
,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用 者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种 退出方法的方式称为“正常调用完成”(Normal Method Invocation Completion)。 - 另外一种退出方式是在方法执行的过程中遇到了
异常
,并且这个异常没有在方法体内得到妥善处理。这种退出方法的方式称为“异常调用 完成(Abrupt Method Invocation Completion)”。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。
♣️ 3.2.5 一些附加信息
《Java虚拟机规范》允许虚拟机实现增加规范里面没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在讨论概念时,一般会把动态链接、方法返回地址与其他附加地址信息全部归为一类,称为栈帧信息。
补充知识
🌻 3.2.6 方法调用
在 JVM
中,将 符号引用
转化为 调用方法
的直接引用与方法的绑定机制相关。
- 静态链接
当一个字节码文件被装载技能 JVM
内部时,如果被调用的 目标方法在编译期可知 ,且运行期保持不变时。这种情况下将调用方法的符号引用转换为 直接引用
的过程称之为 静态链接
。
- 动态链接
如果 被调用的方法在编译期无法被确定下来, 也就说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此被称为 动态链接
。
🌲 3.2.7 栈的相关面试题
- 1、列举栈溢出的情况?
- 2、调整栈大小,就能保证不出现溢出吗?
- 3、分配的栈内存越大越好吗?
- 4、垃圾回收是否会涉及到虚拟机栈?
- 5、方法中定义的局部变量是否线程安全?
答案参考地址: 地址
4、本地方法栈
本地方法栈(Native Method Stacks)
为了虚拟机使用到本地 (Native)
方法夫妇。线程私有,且允许固定大小和动态扩展内存大小。本地方法实现时 C语言实现
。
- 当某个线程调用一个本地方法时,它就就入了一个全新的并且不在受虚拟机限制的世界。它和虚拟机有同样的权限。
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
- 它甚至可以直接使用本地处理器中的寄存器。
- 直接从本地内存的堆中分配任意数量的内存。
- 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确的要求本地方法栈使用的语言、具体实现方式和数据结构等。
5、堆
5.1 概述
- 一个
Java
实例只存在一个堆内存,堆也是Java
内存管理的核心区域。 Java
堆区 在JVM
启动的时候创建,其空间大小也就确定了。是JVM
管理的最大的一块内存。- 堆内存大小是可以调节的。
- 《Java 虚拟机规范》 堆可以处于物理上不连续的内存空间,但是逻辑上它应该被视为连续的。
- 所有的线程共享的
Java
堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer ,TLAB)。
🌲 堆空间大小设置
- Java 堆区用于 存储Java对象实例,那么堆的大小在JVM启动时就已经定好了,大家可以通过选择
-Xmx
和-Xms
来进行设置。-Xms
: 用于表示堆内存起始内存,等价于-XX:InitialHeapSize
。-Xmx
:则用于表示堆区的最大内存,等价于-XX:MaxHeapSize
- 如果堆内存大小超过
-Xmx
所指定的最大内存是,将会抛出OutOfMemoryError
异常。
5.2 堆内存的细分
- Java 7 之前堆内存逻辑上分为三部分:新生区+养老区+永久区
Young Generation Space
:新生区 (划分为Eden
区和Survivor
区)Tenure Generation Space
养老区Permanent Space
:永久区
- Java 8 及以后堆内存逻辑上分为三部分: 新生区+养老区+元空间
Young Generation Space
:新生区 (划分为Eden
区和Survivor
区)Tenure Generation Space
养老区Meta Space
:元空间
⚠️ 注意: 新生区 == 新生代 == 年轻代 、 养老区 == 老年区 == 老年代 、永久区 == 永久代
♣️ 5.2.1 年轻代与老年代
- 存储在
JVM
中的 Java对象可以被划分为两类:- 一类是生命周期比较短的瞬时对象,这类对象的创建和消亡都非常迅速。
- 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与
JVM
的生命周期保持一致。
- Java堆区进一步细分可以分为:
年轻代和老年代
. - 其中年轻代又可划分为
Eden
空间、Survivor0
空间和Survivo1
空间(有时也叫做from 区、to区
)。
如图:
那有么有想过 年轻代和老年代
在 堆空间
的占比是多少呢?
如图:
通过上图我们知道在 堆空间
中 年轻代
占比 1/3
,老年代
占比 2/3
。而我们也可以通过修改 -XX:NewRatio = 4
,表示 新生代
占比 1/5
,相反 老生代
占比 4/5
。
通过上述我们知道了 年轻代和老年代
的默认占比是 1:2
,我们也能看见 From区和To区
在年轻代中的占比是 8:1:1
。当然我们也可以通过 -XX:SurvivoRatio
来进行设置其他比例。
但是我们要知道,几乎所有 的Java对象都是在 Eden
被 new
出来的。
5.3 对象分配过程
这里给大家简单的叙述一下 内存
中对象分配的具体过程。
- 1、
new
的对象放在Eden区
。此区有大小限制。 - 2、当
Eden区
空间填满的时候,程序又需要创建新的对象,则此时JVM
的垃圾回收机制将会对Eden区
进行垃圾回收(Minor GC)
,将Eden区
中的不再被其他对象所引用的对象进行销毁。在加载新的对象到Eden区
. - 3、然后将
Eden 区
中剩余的对象移动到Survivor0区
。 - 4、如果再次触发垃圾回收,此时上次幸存下来的放到
Survivor0区
,如果没有回收则就会放到1区
。 - 5、如果再次经历垃圾回收,此时会重新放回
Survivor0区
接着再去1区
。 - 6、当达到一定的次数,则会放置到
养老区
. 一般默认为15
次。- 可设置参数:
-XX:MaxTenuringThreshold=<N>
进行设置。
- 可设置参数:
总结:
- 对于
0、1区
复制后有交换,谁是空谁是to。- 对于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。
⚠️ 注意: 如果当一个新的对象申请内存大于
Eden区
的内存时,则会放到老年区
如果老年区
放不下则会出现OOM
。当
Survivor
区中相同年龄的所有对象大小总和大于Survivor
空间的一半,年龄大于或等于该年龄的对象 可以直接进入老年代,无须等到MaxTenuringThreshold
中要求的年龄。
5.4 堆空间分代思想
如果在面试中面试官问:为什么堆空间要进行分代,都放在一起不行吗?
此时面试官可能就在考验你是否真正的了解 堆空间
这个概念,和对垃圾回收的一个判断。
就刚才那个问题而言,都放在一起当然可以。但是如果没有分代,把所有的对象都放在一起,就如同把一个学校的所有人都关在教室里面。关在一个教师里面,老师如何去上课?是一起学习还是,一年级先上、六年级不听?这肯定是不合理的,这样就会对堆的所有区域进行扫描,而很多对象都是朝生夕死的。
如果我们分区处理,在年轻代进行垃圾回收频繁,在老年代少一点,元空间几乎不回收。这样能很大的提高性能,且能有效的腾出很大的空间。
5.5 TLAB(Thread Local Allocation Buffer)
🌻 什么是TLAB?
- 从内存模型而不是垃圾收集的角度,对
Eden
区域继续进行划分,JVM
为 每一个线程分配了一个私有缓存区域 ,它包含在Eden
空间内。 - 多线程同时分配内存时,使用
TLAB
可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为 快速分配策略。
🌻 为什么有TLAB?
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据。
- 由于对象实例的创建在
JVM
中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。 - 为了避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
注意:
- 尽管不是所有的对象实例都能在
TLAB
中成功分配内存,但 JVM确实是将TLAB作为内存分配的首选。 - 在程序中,开发人员可以通过
-XX:UseTLAB
设置是否开启TLAB
空间。 - 一旦对象在
TLAB
空间分配内存失败时,JVM
就会尝试着通过 使用加锁机制 确保数据操作的原子性,从而直接在Eden
空间中分配内存。
6、方法区
6.1 概述
方法区(Method Area)
与 Java
堆一样,是各个线程共享的内存空间区域,它用于存储已被虚拟机加载的 类型信息、常量、静态变量、即时编译器编译后的代码缓存
等数据。
🌻 栈、堆、方法区之间的交互关系
例如:
Person person = new Person();
Person
:方法区person
:栈new Person()
:堆
如图:
⚠️ 在jdk7以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。
- 元空间的本质和永久代类似,都是对
JVM
规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用了本地内存。
- 永久代、元空间二者并不只是名字改变了,内部结构也调整了。
- 根据
《Java虚拟机规范》
的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM
异常。
6.2 方法区的内部结构
方法区的内部结构有:
- 类型信息
- 常量
- 静态变量
- 即时编译器编译后的代码缓存
- 运行时常量池
- 方法信息
- …
♣️ 类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM
必须在方法区中存储以下类型信息:
- 1、这个类型的完整有效名称(全名=包名.类名)
- 2、这个类型直接父类的完整有效名(对于
interface
或是java.lang.Object
,都没有父类) - 3、这个类型的修饰符(
public,abstract,final
的某个子集) - 4、这个类型直接接口的一个有序列表
♣️ 域信息
JVM
必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。- 域的信息包括:域名称、域类型、域修饰符(
public,private,prtected,static,final,volatile,transient
的某个子集)
♣️ 方法信息
JVM
必须保存所有方法的以下信息,同 域信息一样包括声明顺序
:
- 方法名称
- 方法的返回类型(或
void
) - 方法参数的数量和类型
- 方法修饰符
- 方法的字节码、操作数栈、局部变量表大小
- 异常表
- 每个异常处理的开始位置、结束为止、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
♣️ non-final 的类变量
- 静态变量和类关联在一起,随着类的加载而加载,它们成为数据在逻辑上的一部分。
- 类变量被类的所有实例共享,即使没有类实例是你也可以访问。
6.3 运行时常量池
-
运行时常量池(Runtime Constant Pool)
是方法区的一部分。 -
常量池表(Constant Pool Table)
是Class
文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 -
一般在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
-
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
- 运行时常量池,相对应
Class
文件中的常量池具有动态性。
- 运行时常量池,相对应
-
运行时常量池类似于传统的编程语言中的符号表
(symbol table)
,但是它所包含的数据比符号表要更加丰富一些。 -
当创建类或接口的运行时常量池,如果构造运行时常量池所需要的内存空间超过了方法区所能提供的最大值,则
JVM
会抛出OutOfMemoryError
异常。
⚠️ 注意:
- 在
jdk1.6
之前 静态变量存放在永久代上。- 在
jdk1.7
中 字符串常量池、静态变量移除,保存在堆中。- 在
jdk1.8
及之后 无永久代,类型信息字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中。那为什么StringTable为什么要调整?
jdk7
中将StringTable
放到了堆空间中。因为永久代的回收效率很低,在full gc
的时候才会触发。而full gc
是老年代的空间不足、永久代不足时才会触发。这就导致StringTable
回收效率不高。而我们开发中会有大量的字符串被创建。回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
6.4 方法区的垃圾回收机制
7、总结图
8、运行时数据区常见面试题
1、说一下JVM内存模型,有哪些区?分别是干什么的?
2、Java8的内存分代改进?
3、栈和堆的区别是什么?堆的结构是什么样子的?为什么有两个Survivor区?
4、Eden和Survivor的比例是多少?
5、Java分区为什么要有年轻代和老年代?
6、什么时候对象会进入老年代?
7、JVM内存模型,Java8做了什么修改?
8、jvm中的永久代会发生垃圾回收吗?
答案地址:地址