1.类加载子系统
类加载子系统将class文件转换成二进制文件加载到jvm中
运行时数据区:红色的部分为线程共享部分,灰色的为线程私有的。
1.1 类加载子系统概念
类加载系统负责从文件系统或者网络中加载class文件,class文件在文件开头必须有特定的标识。
classLoad只负责class文件的加载,至于它是否可以运行是交给执行引擎负责的。加载的类信息存放于一块称作方法区的内存空间,除了加载的类信息外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量
1.2 类加载器ClassLoad的角色
- class file是存放在本地磁盘中,可以理解为设计师在之上的模板,而最终这个模板是需要加载到jvm中根据这个模板加载出一个要一模一样的实例出来
- class file加载到jvm中,被称为DNA元数据模板,放在方法区
在 class 文件 -> JVM -> 最终成为元数据模板,此过程就要一个运输工具(类装载器Class Loader),扮演一个快递员的角色。
1.3 类加载的过程
类的加载过程—-加载
通过一个类的全限定名获取此类的二进制字节流。将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
补充:加载 .class 文件的方式从本地系统中直接加载
- 通过网络获取,典型场景:Web Applet
- 从 zip 压缩包中读取,成为日后 jar、war 格式的基础
- 运行时计算生成,使用最多的是:动态代理技术
- 由其他文件生成,典型场景:JSP 应用从专有数据库中提取 .class 文件,比较少见
- 从加密文件中获取,典型的防 class 文件被反编译的保护措施、
类的加载过程—-连接
符号引用只是一下字面量,直接引用则是真正的内存指针引用
为类变量分配内存并且设置该类变量的默认初始值,即零值。
public class HelloApp {
private static int a = 1; // 准备阶段为0,在下个阶段,也就是初始化的时候才是1
public static void main(String[] args) {
System.out.println(a);
}
}
上面的变量 a 在准备阶段会赋初始值,但不是1,而是0。
类的加载过程—-初始化
- 初始化阶段就是执行类构造器方法
()的过程。 - 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来,也就是说,当我们代码中包含 static 变量的时候,就会有 clinit 方法
- 构造器方法中指令按语句在源文件中出现的顺序执行。
()不同于类的构造器。(关联:构造器是虚拟机视角下的 ( ) ) - 若该类具有父类,JVM会保证子类的
()执行前,父类的 ()已经执行完毕。 - 虚拟机必须保证一个类的
()方法在多线程下被同步加锁。 1.4 类加载器分类
JVM支持两种类型的类加载器,分别为引导类加载器(Bootstarp ClassLoad)和自定义加载器(User-Defined ClassLoad),从概念来说,自定义加载器一般是指程序中由开发人员自定义的一类类加载器,但是java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoad的类加载器都划分为自定义加载器
```java public class ClassLoaderTest { public static void main(String[] args) {
// 获取其上层的:扩展类加载器// 获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
// 试图获取 根加载器ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);
// 获取自定义加载器ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);
// 获取String类型的加载器ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);
} }ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1);
![image.png](https://cdn.nlark.com/yuque/0/2020/png/1862389/1606822020624-5506c1a3-311c-46dd-a6d7-a2d213c47123.png#align=left&display=inline&height=124&margin=%5Bobject%20Object%5D&name=image.png&originHeight=248&originWidth=951&size=94279&status=done&style=none&width=475.5)<br />**虚拟机自带的加载器**<br />**启动类加载器(引导类加载器,Bootstrap ClassLoader)**
- 这个类加载使用 C/C++ 语言实现的,嵌套在 JVM 内部。
- 它用来加载 **Java 的核心库(JAVAHOME/jre/lib/rt.jar、resources.jar 或 sun.boot.class.path路径下的内容),用于提供 JVM 自身需要的类**
- 并不继承自 java.lang.ClassLoader,没有父加载器。
- 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
- 出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类
**扩展类加载器(Extension ClassLoader)**
- Java 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现。
- 派生于 ClassLoader 类
- **父类加载器为启动类加载器**
- 从 java.ext.dirs 系统属性所指定的目录中加载类库,**或从 JDK 的安装目录的 jre/lib/ext 子目录(扩展目录)下加载类库**。如果用户创建的 JAR 放在此目录下,也会自动由扩展类加载器加载。
**应用程序类加载器(系统类加载器,AppClassLoader)**
- Java 语言编写,由 sun.misc.LaunchersAppClassLoader 实现
- 派生于 ClassLoader 类
- 父类加载器为扩展类加载器
- 它负责加载环境变量 **classpath 或系统属性 java.class.path 指定路径下的类**库
- 该类加载是程序中默认的类加载器,一般来说,Java 应用的类都是由它来完成加载
- 通过 ClassLoader#getSystemClassLoader() 方法可以获取到该类加载器
**用户自定义类加载器**<br />在 Java 的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。<br />为什么要自定义类加载器?
- 隔离加载类
- 修改类加载的方式
- 扩展加载源
- 防止源码泄漏
**用户自定义类加载器实现步骤:**
- 开发人员可以通过继承抽象类 java.lang.ClassLoader 类的方式,实现自己的类加载器,以满足一些特殊的需求
- 在 JDK 1.2 之前,在自定义类加载器时,总会去继承 ClassLoader 类并重写 loadClass() 方法,从而实现自定义的类加载类,但是在 JDK 1.2 之后已不再建议用户去覆盖 loadClass() 方法,而是建议把自定义的类加载逻辑写在 findClass() 方法中
- 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承 URIClassLoader 类,这样就可以避免自己去编写 findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
<a name="OaMaz"></a>
## 1.5 双亲委派机制
java虚拟机对class文件采取的是按需加载的方式,也就是说**需要使用该类的时候才将他的class文件加载到内存生成class对象**,**在后面会涉及到一个解释器和编译器的情况**,而且加载某个类的时候,java虚拟机次采用的是双亲委派模式,即把请求交给父类处理,他是一种任务委派模式。<br />**工作原理:**
- 如果一个类加载器收到了类加载的请求,他并不会自己先去加载,而是把这个请求**委托给父类的加载器**去执行。
- 如果父类的加载器还存在其父类的加载器,则**进一步向上委托**,依次递归,请求最终到达顶层的启动类加载器
- 如果父类的加载器可以完成类加载任务,就成功返回,倘若父类的加载器无法完成此加载任务,子加载器才回去尝试加载,这就是双亲委派机制
比如在我们的项目中,自定义一个java.lang.String,他与我们远程的String对象完全一致。但是在我们加载的时候,会直接交给父类的启动类加载器,会直接报错。<br />![image.png](https://cdn.nlark.com/yuque/0/2020/png/1862389/1606823398259-89a6673e-18da-4f11-a126-d28d208e4d78.png#align=left&display=inline&height=292&margin=%5Bobject%20Object%5D&name=image.png&originHeight=583&originWidth=1367&size=281898&status=done&style=none&width=683.5)<br />**双亲委派机制的优势**<br />通过上面的例子,我们可以知道,双亲机制可以
- **避免类的重复加载**
- **保护程序安全,防止核心 API 被随意篡改**
- 自定义类:java.lang.String
- 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang 开头的类)
**沙箱安全机制**<br />自定义 String 类,但是在加载自定义 String 类的时候会率先使用引导类加载器加载,lei而引导类加载器在加载的过程中会先加载 JDK 自带的文件(rt.jar包中java\lang\String.class),报错信息说没有 main 方法,就是因为加载的是 rt.jar 包中的 String 类。这样可以保证对 Java 核心源代码的保护,这就是沙箱安全机制。
<a name="6C9P9"></a>
## 1.6 其他
**如何判断两个class对象是否相同:**
- **类的完整类名必须一致,包括包名**
- **加载这个类的classload(指classload实例对象)必须相同**
换句话说,在 JVM 中,即使这两个类对象(class对象)来源同一个 Class 文件,被同一个虚拟机所加载,但只要加载它们的 ClassLoader 实例对象不同,那么这两个类对象也是不相等的。<br /> **对加载器的引用**<br />JVM 必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么 JVM 会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM 需要保证这两个类型的类加载器是相同的。<br />**类的主动使用和被动使用**
- Java 程序对类的使用方式分为:主动使用和被动使用。
- 主动使用,又分为七种情况:
- 创建类的实例
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(比如:Class.forName("com.atguigu.Test"))
- 初始化一个类的子类
- Java 虚拟机启动时被标明为启动类的类
- JDK 7 开始提供的动态语言支持:
- java.lang.invoke.MethodHandle 实例的解析结果 REF getStatic、REF putStatic、REF invokeStatic 句柄对应的类没有初始化,则初始化
- 除了以上七种情况,其他使用Java 类的方式都被看作是对类的被动使用,都不会导致类的初始化
<a name="pfTLf"></a>
# 2.运行时数据区
本节主要是运行时数据库,也就是下面这张图的,他就是类加载完成后的阶段<br />![image.png](https://cdn.nlark.com/yuque/0/2020/png/1862389/1606994854193-6b96fee7-2a88-4147-87b7-a84e63858721.png#align=left&display=inline&height=416&margin=%5Bobject%20Object%5D&name=image.png&originHeight=832&originWidth=1168&size=180180&status=done&style=none&width=584)<br />图 2-1<br />当我们通过前面的:类的加载->连接->(验证-准备-解析)->初始化,这几个阶段完成后,就会用到执行引擎对我们类进行使用,同时执行引擎将会使用到我们运行时数据区。<br />![image.png](https://cdn.nlark.com/yuque/0/2020/png/1862389/1606995034369-7b1e4672-3c96-4b1a-8fcc-a40320f3a816.png#align=left&display=inline&height=544&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1087&originWidth=1245&size=397692&status=done&style=none&width=622.5)
内存是非常重要的系统资源,是硬盘和cpu的中间仓库及桥梁,承载着操作系统和应用程序的实时运行,jvm内存布局规定了java在运行中内存的申请,分配,管理的策略。 <br />我们通过硬盘或者网络IO得到的数据,都需要加载到内存中,然后cpu从内存中获取数据进行读取。<br />![image.png](https://cdn.nlark.com/yuque/0/2020/png/1862389/1606995305134-996e5843-fa4f-49a9-ad17-8bb67a307a5e.png#align=left&display=inline&height=287&margin=%5Bobject%20Object%5D&name=image.png&originHeight=574&originWidth=1196&size=1095717&status=done&style=none&width=598)<br />java虚拟机定义了若干种程序运行期间会使用到的运行时数据,其中会随着虚拟机的启动而创建,随着虚拟机的销毁而销毁。另一些则是随着线程一一对应的,与线程的生命周期相同步。<br />再图2-1中,红色部分为多个线程共享,灰色部分为单独线程私有的;<br />每个线程:独立的程序计数器,虚拟机栈,本地方法栈<br />线程共享:堆,堆外内存(永久代或元空间、代码缓存)
<a name="UEj8z"></a>
## 2.1 程序计数器
<a name="sU3sG"></a>
### 2.1.1 介绍
JVM 中的程序计数寄存器(Program Counter Register)中,Register 的命名源于 CPU 的寄存器,寄存器存储指令相关的现场信息。CPU 只有把数据装载到寄存器才能够运行。这里,并非是广义上所指的物理寄存器,或许将其翻译为 PC 计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM 中的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟。<br />![image.png](https://cdn.nlark.com/yuque/0/2020/png/1862389/1606996318155-0517111a-5fc9-407b-8e0f-b3c588400a1c.png#align=left&display=inline&height=193&margin=%5Bobject%20Object%5D&name=image.png&originHeight=385&originWidth=1397&size=431763&status=done&style=none&width=698.5)<br />**特性:**
- 程序计数器是一块很小的内存空间,几乎可以忽略不记,也是运行时数据区速度最快的区域
- 在jvm规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期和线程的生命周期保持一致
- 任何时间一个线程都只有一个方法在执行,就是所谓的当前方法,程序计数器会存储当前线程正在执行的java方法的jvm指令地址。
- 他是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复,等基础功能都需要依赖这个程序计数器
- 字节码解释器工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
- 他是唯一在java虚拟机规范中没有OutOfMemoryError情况的区域。
**PC寄存器(程序计数器)用来存储指向下一条指令的地址,也就是即将要执行的指令代码,由执行引擎读取下一条指令。**<br />**![image.png](https://cdn.nlark.com/yuque/0/2020/png/1862389/1606997392213-6df43841-d1e8-48c7-8f5e-996baa86587e.png#align=left&display=inline&height=376&margin=%5Bobject%20Object%5D&name=image.png&originHeight=752&originWidth=881&size=377704&status=done&style=none&width=440.5)**
<a name="GpdOk"></a>
### 2.1.2 举例
简单代码
```java
public class PCRegisterTest {
public static void main(String[] args) {
int i = 10;
int j = 20;
int k = i + j;
}
}
然后将代码进行编译成字节码文件,我们再次查看 ,发现在字节码的左边有一个行号标识,它其实就是指令地址,用于指向当前执行到哪里。
通过 PC 寄存器,我们就可以知道当前程序执行到哪一步了
2.1.3 常见问题
使用PC寄存器存储字节码指令地址有什么用?
因为cpu需要不停的切换线程,当我们线程切换回来的时候,需要知道目前接着从哪里开始执行,JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的指令。
PC寄存器为什么设置为私有的?
我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU 会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个 PC 寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。
由于 CPU 时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。
这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
2.2 虚拟机栈
2.2.1 概述
由于跨平台 性的设计,java的指令都是根据栈来设计的,不同平台的cpu架构不同,所以不能设计为基于寄存器。
优点是跨平台,指令集小,编辑器容易实现
缺点是性能下降
栈是运行时单位,堆是存储的单位
- 栈解决程序的运行问题,即程序怎么处理,或者说如何处理数据
- 堆解决的是存储问题,即数据怎么放,放哪里
JAVA虚拟机栈是什么
java虚拟机早期也叫java栈,每个线程在创建的时候会创建一个虚拟机栈,其内部都会保存一个个的栈帧,对应一个个的java方法。
线程私有
生命周期:与线程一致
作用:主管java程序的运行,它保存方法的局部变量,部分结果,并参与方法的调用和返回。
栈的特点(优点)
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。JVM直接对java栈的操作只有两个:
- 每个方法执行,伴随着进栈(入栈,压栈)
- 执行结束后的出栈工作
对于栈来说不存在垃圾回收问题:
- 栈存在溢出的情况OOM
- 不存在GC
栈中可能出现的异常:
java虚拟机规范允许java栈的大小是动态的或者固定不变的。
如果采用固定大小的java虚拟机 栈,那么每个线程的java虚拟机栈容量在线程创建的时候独立选定,如果线程请求分配的栈容量超过了java虚拟机栈所允许的最大容量,java虚拟机栈就会抛出一个stackoverflowError异常。
如果java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程的时候没有足够的内存去创建对应的虚拟机栈,就java虚拟机就会抛出一个OutOfMemoryError异常。
2.2.1 栈的存储单位
栈中存储的是什么:
- 每个线程都有自己的栈,栈中的数据都是以栈帧的格式存在
- 在这个线程上正在执行的每个方法都对应一个栈帧
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
栈运行的原理:
- java直接对java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循”先进后出\后进先出”的原理
- 在一条活动线程上,一个时间点上,只会又一个活动的栈帧,即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
- 执行引擎运行的所有字节码指令只针对当前栈帧做操作
- 如果在该方法中调用了其他方法,对应新的栈帧就会创建出来,放在栈的顶端,成为新的当前栈。
代码
不同线程的栈帧之间是不允许相互调用的
如果当前方法调用了其他方法,方法返回之际,当前栈帧回传会此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使前一个栈帧重新成为当前栈。
java函数有两种返回函数的方式,一种是正常的函数返回,使用return,另一种是抛出异常,不管使用哪种方式,都会导致栈帧被弹出。
栈帧的内部结构:
每个栈帧中存储着:
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)(或表达式栈)
- 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
- 一些附加信息
并行每个线程下的栈都是私有的,因此每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要由局部变量表和操作数栈决定的
局部变量表
局部变量表,local variables,被称之为局部变量数组或者是本地变量表。
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各种类的基本数据类型,对象引用(对象的实例还是存放在堆中的),以及returnAddress类型。
局部变量表是建立在线程的栈上,是线程私有数据,所以不存在安全问题。
局部变量表所需的容量大小都是在编译器就能确定下来的,在方法运行期间是不会改变局部变量表的大小的。
方法嵌套使用的次数由栈的大小决定,一般来说,栈越大,方法嵌套的次数就越多。
对于一个函数来说,它的参数和局部变量越多,局部变量表就越大,它的栈帧就越大。进而函数调用就会占用越大的栈空间,使方法嵌套的次数就变小。局部变量表中的变量只在当前方法调用中有效,局部变量表是随着当前栈帧的销毁而销毁的。
举例:静态变量和局部变量的区别
变量的分类:
- 按数据类型分
- 基本数据类型
- 引用数据类型
- 按类中申明的位置分
- 成员变量(类变量也就静态变量,实例变量)类变量也叫静态变量,也就是在变量前加了static 的变量;实例变量也叫对象变量,即没加static 的变量;
- 局部变量
类变量:linking 的 prepare 阶段,给类变量默认赋值,init 阶段给类变量显示赋值即静态代码块
实例变量:随着对象创建,会在堆空间中分配实例变量空间,并进行默认赋值
局部变量:在使用前必须进行显式赋值,不然编译不通过。
参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。
我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。
和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
补充说明
在栈帧中,与性能调优关系最为密切的部分就是局部变量表,在方法执行时,虚拟机使用局部变量表完成方法的传递
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用了的对象就不会被回收。
操作数栈
每个独立的栈帧中除了包括局部变量表之外,还包括一个后进先出的操作数栈,也可以称之为表达式栈
操作数栈,在方法执行的过程中,根据字节码指令,往栈中写入数据或者提取数据,即入栈和出栈。
- 某些字节码指令将值压入操作数栈,其余指令将操作数取出栈,使用后再把结果压入栈
- 比如执行复制、交换、求和等等
操作数栈,主要就是用于保存计算过程的中间结果,同时作为计算过程中变量临时的储存空间。
2.3本地方法栈
2.3.1本地方法接口
简单来讲,一个 Native Method 是一个 Java 调用非 Java 代码的接囗。一个 Native Method 是这样一个 Java 方法:该方法的实现由非 Java 语言实现,比如 C。这个特征并非 Java 所特有,很多其它的编程语言都有这一机制,比如在 C++ 中,你可以用 extern “c” 告知 C++ 编译器去调用一个 C 的函数。
2.3.2本地方法栈
- java虚拟机栈是用来管理java方法的调用,而本地方法栈则是用来管理本地方法的调用
- 本地方法栈,线程私有
- 允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverFlowError 异常。
- 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java 虚拟机将会抛出一个 OutOfMemoryError 异常
- 本地方法时使用c语言实现的
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
2.3堆
2.3.1堆的核心概念
堆和方法区针对一个jvm实例来说是唯一的,也就是一个进程只有一个jvm,一个进程可以有多个线程。他们是共享堆和方法区的。每个线程各自包括一套程序计数器,虚拟机栈和本地方法栈。
一个jvm实例只有一个堆内存,堆也是java内存管理的核心区域
- java堆区在jvm启动的时候就被创建,其空间大小也就确定了。是jvm管理的最大一块内存空间。(堆内存的空间是可以调节的)
- 堆可以处于物理上不连续的内存空间中,但是在逻辑上应该是连续的。
- 所有的线程共享堆内存,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)
举例
-Xms10m:初始堆内存
-Xmx10m最大堆内存
- 《java虚拟机规范》中对java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆中(数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除),但是有一些对象是在栈上分配的(后面再说)
- 如果堆中对象马上被回收了,那么用户线程就会收到影响,因为有 Stop The World
堆,是 GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
重点图
一个进程对应一个jvm实例,一个进程有多个线程
2.3.2 堆内存的细分
现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:
Java 7 及之前堆内存逻辑上分为三部分:新生区+养老区+永久区
Ø Young Generation Space 新生区 Young/New 又被划分为 Eden 区和Survivor 区
Ø Tenure Generation Space 养老区 Old/Tenure
Ø Permanent Space 永久区 Perm
Java 8 及之后堆内存逻辑上分为三部分:新生区+养老区+元空间
Ø Young Generation Space 新生区 Young/New
○ 又被划分为 Eden 区和 Survivor 区
Ø Tenure Generation Space 养老区 Old/Tenure
Ø Meta Space 元空间 Meta
约定:
新生区 <=> 新生代 <=> 年轻代 、
养老区 <=> 老年区 <=> 老年代、
永久区 <=> 永久代
2.3.3 设置堆内存的大小
- “-Xms” 用于表示堆区(年轻代+老年代)的起始内存,等价于 -XX:InitialHeapSize
- “-Xmx” 则用于表示堆区(年轻代+老年代)的最大内存,等价于 -XX:MaxHeapSize
开发中建议将初始堆内存和最大堆内存设置成相同的值 ,防止堆多次扩容
默认情况下:
- 初始内存大小:物理电脑内存大小/64
- 最大内存大小:物理电脑内存大小/4
如何查看堆内存的内存分配情况
先配置参数-Xmx600m -Xms600m -XX:+PrintGCDetails
为什么设置初始堆内存为600M,实际只有575M?
答:因为在新生代中,数据存放在 Eden 区和 Survivor 区,其中 Survivor0 和 Survivor1 区只能二选一存放,少了一个25600 / 1024 = 25M。
2.3.4 OOM
官方介绍为当JVM因为没有足够的内存来为对象分配空间并且垃圾回收器也已经没有空间可回收时,就会抛出 java.lang.OutOfMemoryError :··· (注意:这是个很严重的问题,因为这个问题已经严重到不足以被应用处理)
具体原因大致由两种:
1.自身原因:比如虚拟机本身可使用内存太少
2.外在原因:应用使用太多,用完没有释放,浪费了内存。
内存泄露:申请使用完的内存没有释放,导致虚拟机不能再次使用该内存,此时这段内存就泄漏了,当内存泄漏过多的时候,就会发生内存溢出。
内存溢出:申请的内存超过了jvm能提供的内存大小,或者堆中已经没有足够的空间来存入新的对象。
常见OOM情况:
- java.lang.OutOfMemoryError: Java heap space ———>java堆内存溢出,此种情况最常见,一般由于内存泄露或者堆的大小设置不当引起。对于内存泄露,需要通过内存监控软件查找程序中的泄露代码,而堆大小可以通过虚拟机参数-Xms,-Xmx等修改。
- java.lang.OutOfMemoryError: PermGen space ———>java永久代溢出,即方法区溢出了,一般出现于大量Class或者jsp页面,或者采用cglib等反射机制的情况,因为上述情况会产生大量的Class信息存储于方法区。当出现此种情况时可以通过更改方法区的大小来解决,使用类似-XX:PermSize=64m -XX:MaxPermSize=256m的形式修改。注意,过多的常量尤其是字符串也会导致方法区溢出。
- java.lang.StackOverflowError ———> 不会抛OOM error,但也是比较常见的Java内存溢出。JAVA虚拟机栈溢出,一般是由于程序中存在死循环或者深度递归调用造成的,栈大小设置太小也会出现此种溢出。可以通过虚拟机参数-Xss来设置栈的大小。
虚拟机栈的大小如果是可以扩展的情况下,当扩展的过程中申请不到足够的内存空间,也会报OOM错误。
减少GC开销的措施
1.不要显式调用System.gc()
此函数建议JVM进行主GC,虽然只是建议而非一定,但很多情况下它会触发主GC,从而增加主GC的频率,也即增加了间歇性停顿的次数。
2.尽量减少临时对象的使用
临时对象在跳出函数调用后,会成为垃圾,少用临时变量就相当于减少了垃圾的产生,从而延长了出现上述第二个触发条件出现的时间,减少了主GC的机会。
3.对象不用时最好显式置为Null
一般而言,为Null的对象都会被作为垃圾处理,所以将不用的对象显式地设为Null,有利于GC收集器判定垃圾,从而提高了GC的效率。
4.尽量使用StringBuffer,而不用String来累加字符串
使用String进行大量繁琐字符串拼加时,会产生大量临时对象,使剩余内存空间碎片话。此时剩余内存总量不少,但可能无法分配出满足指定大小的剩余内存区间,产生内存抖动现象,频繁GC占用过多硬件资源,造成卡顿,甚至出现OOM。
5.谨慎使用静态变量
静态变量属全局变量,不进行GC回收,它持有的对象也不会回收。
储存在JVM中的java对象一般可以分为两类
- 一类是生命周期比较短的瞬时对象,这类对象的创建和消亡都非常迅速-生命周期短的,及时回收即可。
- 另外一类对象的生命周期非常长,某些极端情况下还能够与jvm的生命周期保持一致
jvm堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)
其中年轻代又可以划分为 Eden 空间、Survivor0 空间和 Survivor1 空间(有时也叫做 From 区、To 区)
下面这些参数在开发中一般不去调整:
- Eden : From : To -> 8 : 1 : 1
- 新生代 : 老年代 - > 1 : 2
配置新生代与老年代在堆结构的占比。
- 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
- 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
- 当发现在整个项目中,生命周期长的对象偏多,那么就可以通过调整老年代的大小,来进行调优
- 在HotSpot 中,Eden 空间和另外两个 Survivor 空间缺省所占的比例是8 : 1 : 1,当然开发人员可以通过选项“-XX:SurvivorRatio”调整这个空间比例。比如-XX:SurvivorRatio=8
为什么默认是8:1:1,而实际当中是6:1:1?
答:因为存在自适应机制,即-XX:-UseAdaptiveSizePolicy(+启用,-禁用),但这种方法一般不能生效,所以一般采用-XX:SurvivorRatio=8
- 几乎所有的 Java对象都是在 Eden 区被 new 出来的。
- 绝大部分的 Java对象的销毁都在新生代进行了。(有些大的对象在 Eden 区无法存储时候,将直接进入老年代)
- IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。
- 可以使用选项”-Xmn”设置新生代最大内存大小(优先级高于-XX:NewRatio)
- 这个参数一般使用默认值就可以了。
2.3.6 图解对象分配过程
- new的对象先放Eden区(伊甸区),此区有大小限制
- 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(YougGC/MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区.
- 然后将伊甸园中的剩余对象移动到幸存者0区。from区和to区,每次垃圾回收,到会将幸存者放在to区,然后将from区的没有回收的放在to区age+1
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
- 如果再次经历垃圾回收,此时会重新放回 Survivor0 区,接着再去 Survivor1 区。
- 啥时候能去养老区呢?可以设置次数。默认是15次。
- 可以设置参数:-Xx:MaxTenuringThreshold=N进行设置
- 在养老区,相对悠闲。当养老区内存不足时,再次触发GC : Major GC,进行养老区的内存清理
- 若养老区执行了Major GC 之后,发现依然无法进行对象的保存,就会产生 OOM 异常。
我们创建的对象,一般都是存放在 Eden 区的,当我们 Eden 区满了后,就会触发 GC 操作,一般被称为 YGC / Minor GC 操作,幸存者区内存满了不会主动触发垃圾回收,等有eden区满了才会顺带着回收幸存者区的垃圾。实在满了比较特殊,可以直接晋升为老年代。
当我们进行一次垃圾收集后,红色的将会被回收,而绿色的还会被占用着,存放在 S0(Survivor From) 区。同时我们给每个对象设置了一个年龄计数器,一次回收后就是1。
同时 Eden 区继续存放对象,当 Eden 区再次存满的时候,又会触发一个 Young/MinorGC 操作,此时 GC 将会把 Eden 和 Survivor From 中的对象进行一次收集,把存活的对象放到 Survivor To区,同时让年龄 + 1
思考:幸存区区满了后?
特别注意,在 Eden 区满了的时候,才会触发 Minor GC,而 Survivor 区满了后,不会触发 Minor GC 操作
如果 Survivor 区满了后,将会触发一些特殊的规则,也就是可能直接晋升老年代
举例:以当兵为例,正常人的晋升可能是 : 新兵 -> 班长 -> 排长 -> 连长
但是也有可能有些人因为做了非常大的贡献,直接从 新兵 -> 排长
总结:
针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to.
关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。
2.3.7 Minor GC、Major GC、Full GC
- Minor GC:新生代的 GC
- Major GC:老年代的 GC
- Full GC:整堆收集,收集整个 Java 堆和方法区的垃圾收集
我们都知道,JVM 的调优的一个环节,也就是垃圾收集,我们需要尽量的避免垃圾回收,因为在垃圾回收的过程中,容易出现 STW 的问题
而 Major GC 和 Full GC 出现 STW 的时间,是 Minor GC 的10倍以上
JVM 在进行 GC 时,并非每次都对上面三个内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)
- 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):只是老年代的圾收集。
- 目前,只有CMS GC 会有单独收集老年代的行为。
- 注意,很多时候Major GC会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收。
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
- 目前,只有G1 GC 会有这种行为
- 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。
Minor GC
- 年轻代GC(Minor GC)触发机制:
- 当年轻代空间不足时,就会触发Minor GC ,这里的年轻代满指的是 Eden 代满,Survivor 满不会引发 GC 。(每次 Minor GC 会清理年轻代的内存。)
- 因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
- Minor GC 会引发 STW (Stop The World),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
Major GC
老年代GC(Major GC/Full GC)触发机制:
- 指发生在老年代的GC ,对象从老年代消失时,我们说 “Major GC” 或 “Full GC” 发生了
- 出现了Major GC ,经常会伴随至少一次的 Minor GC (但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行Major GC 的策略选择过程)
- 也就是在老年代空间不足时,会先尝试触发Minor GC 。如果之后空间还不足,则触发 Major GC
- MajorGC 的速度一般会比 Minor GC 慢10倍以上, STW 的时间更长
- 如果Major GC 后,内存还不足,就报 OOM 了
Full GC
触发 Full GC 执行的情况有如下五种:
- 调用 System.gc() 时,系统建议执行 Full GC ,但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC 后进入老年代的平均大小大于老年代的可用内存
- 由 Eden区、Survivor space0(From Space)区向 Survivor space1(To Space)区复制时,对象大小大于 ToSpace 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
说明:Full GC 是开发或调优中尽量要避免的。这样暂时时间会短一些
GC 举例·
我们编写一个OOM的异常
public class GCTest {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "baobao blog";
while (true) {
list.add(a);
a = a + a;
i++;
}
} catch (Exception e) {
e.getStackTrace();
}
}
}
设置 JVM 启动参数
-Xms9m -Xmx9m -XX:+PrintGCDetails
打印出的日志
触发OOM的时候,一定是进行了一个Full GC,因为只有老年代空间不足的时候,才会爆出OOM异常
2.3.8 堆空间分代思想
为什么要把 Java 堆分代?不分代就不能正常工作了吗?
经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。
- 新生代:有Eden 、两块大小相同的 Survivor(又称为 From/To,S0/S1)构成,To 总为空。
- 老年代:存放新生代中经历多次 GC 仍然存活的对象。
其实不分代完全可以,分代的唯一理由就是优化GC 性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。 GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当 GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
2.3.9 内存分配策略
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 区中每熬过一次 Minor GC ,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个 JVM 、每个 GC 都有所不同)时,就会被晋升到老年代
对象晋升老年代的年龄阀值,可以通过选项 -XX:MaxTenuringThreshold 来设置
针对不同年龄段的对象分配原则如下所示:优先分配到Eden
- 开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发Major GC 的次数比 Minor GC 要更少,因此可能回收起来就会比较慢
- 大对象直接分配到老年代
- 尽量避免程序中出现过多的大对象
- 长期存活的对象分配到老年代
- 动态对象年龄判断
- 如果Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold 中要求的年龄。
空间分配担保:
堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
什么是 TLAB?
- 从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分, JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden空间内。
- 多线程同时分配内存时,使用 TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
- 据我所知所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计。
TLAB针对的是多个线程中对象在分配内存空间的时候。
TLAB说明:尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。
- 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB 空间。
- 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden空间的1%,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置 TLAB 空间所占用 Eden 空间的百分比大小。
- 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden空间中分配内存。
对象分配过程:TLAB
对象首先是通过 TLAB 开辟空间,如果不能放入,那么需要通过 Eden 来进行分配
2.3.11 小结堆空间的参数设置
- 官网 https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
- -XX:+PrintFlagsInitial:查看所有的参数的默认初始值
- -XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
- -Xms:初始堆空间内存(默认为物理内存的1/64)
- -Xmx:最大堆空间内存(默认为物理内存的1/4)
- -Xmn:设置新生代的大小。(初始值及最大值)
- -XX:NewRatio:配置新生代与老年代在堆结构的占比
- -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
- -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
- -XX:+PrintGCDetails:输出详细的GC处理日志
- 打印gc简要信息:①-Xx:+PrintGC ② - verbose:gc
- -XX:HandlePromotionFalilure:是否设置空间分配担保
在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
- 如果大于,则此次 Minor GC 是安全的
- 如果小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允担保失败。
- 如果 HandlePromotionFailure=true ,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
- 如果大于,则尝试进行一次 Minor GC ,但这次 Minor GC 依然是有风险的;
- 如果小于,则改为进行一次 Full GC 。
- 如果 HandlePromotionFailure=false,则改为进行一次 Full GC 。
在 JDK 6 Update24 之后,HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察 OpenJDK 中的源码变化,虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。 JDK6 Update24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC ,否则将进行 Full GC 。
2.3.12 堆是对象分配的唯一选择吗?(逃逸分析)
在《深入理解Java虚拟机》中关于 Java 堆内存有这样一段描述:
随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
在 Java 虚拟机中,对象是在 Java 堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
此外,前面提到的基于 OpenJDK 深度定制的 TaoBao VM ,其中创新的 GCIH(GC Invisible Heap)技术实现Off-Heap,将生命周期较长的 Java 对象从 Heap 中移至 Heap 外,并且 GC 不能管理 GCIH 内部的 Java 对象,以此达到降低 GC 的回收频率和提升 GC 的回收效率的目的。
逃逸分析概述
- 如何将堆上的对象分配到栈,需要使用逃逸分析手段。
- 这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
- 通过逃逸分析, Java HotSpot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
- 逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
如何快速的判断是否发生了逃逸分析,就看 new 的对象是否在方法外被调用。
逃逸分析举例
没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除,每个栈里面包含了很多栈帧,也就是发生逃逸分析
针对下面的代码
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
如果想要 StringBuffer sb 不发生逃逸,可以这样写
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
完整的逃逸分析代码举例
public class EscapeAnalysis {
public EscapeAnalysis obj;
/*
方法返回EscapeAnalysis对象,发生逃逸
@return
/
public EscapeAnalysis getInstance() {
return obj == null ? new EscapeAnalysis():obj;
}
/*
为成员属性赋值,发生逃逸
*/
public void setObj() {
this.obj = new EscapeAnalysis();
}
/*
对象的作用于仅在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis() {
EscapeAnalysis e = new EscapeAnalysis();
}
/*
引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis2() {
EscapeAnalysis e = getInstance();
// getInstance().XXX 发生逃逸
}
}
参数设置
在 JDK 6u23 版本之后, HotSpot 中默认就已经开启了逃逸分析
如果使用的是较早的版本,开发人员则可以通过:
- 选项 “-XX:+DoEscapeAnalysis” 显式开启逃逸分析
- 通过选项 “-XX:+PrintEscapeAnalysis” 查看逃逸分析的筛选结果
结论
开发中能使用局部变量的,就不要使用在方法外定义。
逃逸分析:代码优化
使用逃逸分析,编译器可以对代码做如下优化:
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配
- 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。
栈上分配
- JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
- 常见的栈上分配的场景:
- 在逃逸分析中,已经说明了。分别是给成员变量赋值、方法返回值、实例引用传递。
举例
我们通过举例来说明 开启逃逸分析和未开启逃逸分析时候的情况
class User{
private String name;
private String age;
private String gender;
private String phone;
}
public class StackAllocation {
public static void main(String[] args) throws InterruptedException {
longstart = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
longend = System.currentTimeMillis();
System.out.println(“花费的时间为:” + (end - start) + “ ms”);
// 为了方便查看堆内存中对象个数,线程sleep
Thread.sleep(10000000);
}
private static void alloc() {
User user = new User();
}
}
设置 JVM 参数,表示未开启逃逸分析
-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
运行结果,同时还触发了 GC 操作
花费的时间为:664 ms
然后查看内存的情况,发现有大量的 User 存储在堆中
我们再开启逃逸分析
-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
然后查看运行时间,我们能够发现花费的时间快速减少,同时不会发生 GC 操作
花费的时间为:5 ms
然后再看内存情况,我们发现只有很少的 User 对象,说明 User 发生了逃逸,因为他们存储在栈中,随着栈的销毁而消失
同步省略
线程同步的代价是相当高的,同步的后果是降低并发性和性能。
在动态编译同步块的时候, JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
例如下面的代码
public void f() {
Object hellis = new Object();
synchronized(hellis) {
System.out.println(hellis);
}
}
代码中对 hellis 这个对象加锁,但是 hellis 对象的生命周期只在 f() 方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉,优化成:
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}
我们将其转换成字节码
分离对象和标量替换
标量(Scalar)是指一个无法再分解成更小的数据的数据。 Java 中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量(Aggregate), Java 中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
public static void main(String args[]) {
alloc();
}
class Point{
private int x;
private int y;
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println(“point.x” + point.x + “;point.y” + point.y);
}
以上代码,经过标量替换后,就会变成
private static void alloc() {
int x = 1;
int y = 2;
System.out.println(“point.x = “ + x + “; point.y=” + y);
}
可以看到,Point 这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。 标量替换为栈上分配提供了很好的基础。
标量替换
public class StackAllocation {
public static void main(String[] args) {
longstart = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
// 查看执行时间
longend = 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 {
}
}
首先配置参数 不开启标量替换(默认是开启的)
-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
-XX:-EliminateAllocations
有GC 运行时间长
然后开启标量替换
-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
-XX:+EliminateAllocations
无GC 运行时间短
逃逸分析总结
上述代码在主函数中进行了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 两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配
逃逸分析的不足
- 关于逃逸分析的论文在1999年就已经发表了,但直到 JDK 1.6 才有实现,而且这项技术到如今也并不是十分成熟的。
- 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
- 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
- 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。栈上分配实际上是逃逸分析,加上标量替换,实际上没有逃逸的对象被转换成标量分配在栈上了,就不是对象了。所以对象还是分配在堆中的。
- 注意到有一些观点,认为通过逃逸分析, JVM 会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于 JVM 设计者的选择。据我所知, Oracle HotSpot JVM 中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
目前很多书籍还是基于 JDK 7 以前的版本, JDK 已经发生了很大变化,intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。
2.4 方法区
2.4.1 堆、栈、方法区的交互关系
这次所讲述的是运行时数据区的最后一个部分——方法区
从线程共享与否的角度来看
方法区只是一个概念性的东西,在java8中,用元空间来实现了方法区。
ThreadLocal:如何保证多个线程在并发环境下的安全性?典型应用就是数据库连接管理,以及会话管理
栈、堆、方法区的交互关系
下面就涉及了对象的访问定位Person:存放在元空间,也可以说方法区
- person:存放在 Java 栈的局部变量表中
-
2.4.2 方法区的理解
《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于 HotSpot JVM 而言,方法区还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开。
所以,方法区看作是一块独立于 Java 堆的内存空间。
方法区主要存放的是 Class ,而堆中主要存放的是实例化的对象 方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域。
- 方法区在 JVM 启动的时候被创建,并且它的实际的物理内存空间中和 Java 堆区一样都可以是不连续的(逻辑上连续,物理上可以不连续)。
- 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space 或者 java.lang.OutOfMemoryError:Metaspace
- 加载大量的第三方的 jar 包
- Tomcat 部署的工程过多(30~50个)
- 大量动态的生成反射类
关闭 JVM 就会释放这个区域的内存。
HotSpot 中方法区的演进在 JDK 7 及以前,习惯上把方法区,称为永久代。 JDK 8 开始,使用元空间取代了永久代。
- JDK 1.8 后,元空间存放在堆外内存中(In JDK 8, classes metadata is now stored in the native heap and this space is called Metaspace.)
- 本质上,方法区和永久代并不等价,仅是对 HotSpot 而言的。《Java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEAJRockit / IBM J9 中不存在永久代的概念。
现在来看,当年使用永久代,不是好的idea。导致 Java 程序更容易 OOM (超过-XX:MaxPermsize上限)
而到了 JDK 8 ,终于完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间(Metaspace)来代替
元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
永久代、元空间二者并不只是名字变了,内部结构也调整了
根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出 OOM 异常2.4.3 设置方法区大小和OOM
方法区的大小不必是固定的, JVM 可以根据应用的需要动态调整。
JDK 7 及以前
- 通过 -XX:PermSize 来设置永久代初始分配空间。默认值是20.75M
- -XX:MaxPermSize 来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
- 当 JVM 加载的类信息容量超过了这个值,会报异常 OutOfMemoryError:PermGen space。
JDK 8 以后
- 元数据区大小可以使用参数 -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 设置为一个相对较高的值。
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);
//指明版本号,public,类名,包名,父类,接口
classWriter.visit(Opcodes.V18, Opcodes.ACC_PUBLIC, “Class” + i, null, “java/lang/Object”, null);
byte[] code = classWriter.toByteArray();
//类的加载
_ test.defineClass(“Class” + i, code, 0, code.length);
j++;
}
} finally {
System.out.println(j);
}
}
}
运行结果:
如何解决这些 OOM要解决 OOM 异常或 Heap Space 的异常,一般的手段是首先通过内存映像分析工具(如 Eclipse Memory Analyzer)对 dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
- 内存泄漏就是有大量的引用指向某些对象,但是这些对象以后不会使用了,但是因为它们还和 GC ROOT 有关联,所以导致以后这些对象也不会被回收,这就是内存泄漏的问题
- 内存泄漏得不到解决,从而占据满整个内存空间就会造成内存溢出
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到 GC Roots 的引用链。于是就能找到泄漏对象是通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及 GC Roots 引用链的信息,就可以比较准确地定位出泄漏代码的位置。
如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与 -Xms ),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
2.4.4 方法区的内部结构
《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM 必须在方法区中存储以下类型信息:这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)
- 这个类型的修饰符(public,abstract,final的某个子集)
这个类型直接接口的一个有序列表
域(Field)信息JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient 的某个子集)
方法(Method)信息
JVM 必须保存所有方法的以下信息,同域信息一样包括声明顺序:方法名称
- 方法的返回类型(或 void )
- 方法参数的数量和类型(按顺序)
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
异常表(abstract和native方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
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 void hello() {
System.out.println(“Hello!”);
}
}
运行结果:
Hello!
1
如上代码所示,即使我们把 order 设置为 null ,也不会出现空指针异常
全局常量
全局常量就是使用 static final 进行修饰
被声明为 final 的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。2.4.5 运行时常量池 VS 常量池
运行时常量池,就是运行时常量池
方法区,内部包含了运行时常量池
- 字节码文件,内部包含了常量池
- 要弄清楚方法区,需要理解清楚 ClassFile ,因为加载类的信息都在方法区。
要弄清楚方法区的运行时常量池,需要理解清楚 ClassFile 中的常量池。
常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用
为什么需要常量池
一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。
比如:如下的代码:
public class SimpleClass {
public void sayHello() {
System.out.println(“hello”);
}
}
虽然上述代码只有194字节,但是里面却使用了 String、System、PrintStream 及 Object 等结构。这里的代码量其实很少了,如果代码多的话,引用的结构将会更多,这里就需要用到常量池了。
常量池中有什么
几种在常量池内存存储的数据类型包括:数量值
- 字符串值
- 类引用
- 字段引用
方法引用
例如下面这段代码
public class MethodAreaTest2 {
public static void main(String args[]) {
Object obj = new Object();
}
}
Object obj = new Object();将会被翻译成如下字节码
new #2 //Class java/lang/Object
dup
invokespecial //Method java/lang/Object”“()V
小结
常量池、可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型
基本数据类型,如果是方法中的局部变量,则放在虚拟机栈中的局部变量表中,而如果是成员变量,则存放在常量池的中
运行时常量池运行时常量池(Runtime Constant Pool)是方法区的一部分。
- 常量池表(Constant Pool Table)是 Class 文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
- 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
- JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
- 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
- 运行时常量池,相对于 Class 文件常量池的另一重要特征是:具备动态性。
- String.intern()
- 运行时常量池,相对于 Class 文件常量池的另一重要特征是:具备动态性。
- 运行时常量池类似于传统编程语言中的符号表(Symbol Table),但是它所包含的数据却比符号表要更加丰富一些。
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛 OutOfMemoryError 异常。
2.3.6 方法区使用举例
如下代码
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);
}
}
字节码执行过程展示
首先现将操作数500放入到操作数栈中
然后存储到局部变量表中
然后重复一次,把100放入局部变量表中,最后再将变量表中的500和100取出,进行操作
将500和100进行一个除法运算,在把结果入栈
在最后就是输出流,需要调用运行时常量池的常量
最后调用 invokevirtual(虚方法调用),然后返回
返回时
程序计数器始终计算的都是当前代码运行的位置,目的是为了方便记录方法调用后能够正常返回,或者是进行了CPU 切换后,也能回来到原来的代码进行执行。
6 方法区的演进细节首先明确:只有 HotSpot 才有永久代。BEA JRockit、IBMJ9 等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一
HotSpot 中方法区的变化: | JDK1.6及以前 | 有永久代,静态变量存储在永久代上 | | —- | —- | | JDK1.7 | 有永久代,但已经逐步 “去永久代”,字符串常量池,静态变量移除,保存在堆中 | | JDK1.8及以后 | 无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池、静态变量仍然在堆中。 |
JDK 6 的时候
JDK 7 的时候
JDK 8 的时候,元空间大小只受物理内存影响
为什么永久代要被元空间替代?
http://openjdk.java.net/jeps/122
JRockit 是和 HotSpot 融合后的结果,因为 JRockit 没有永久代,所以他们不需要配置永久代
随着 Java 8 的到来,HotSpot VM 中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)。
由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间
这项改动是很有必要的,原因有:
- 为永久代设置空间大小是很难确定的。
在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OOM 。比如某个实际 Web 工 程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。
“Exception in thread‘dubbo client x.x connector’java.lang.OutOfMemoryError:PermGen space”
而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。 因此,默认情况下,元空间的大小仅受本地内存限制。
- 对永久代进行调优是很困难的。
- 主要是为了降低 Full GC
有些人认为方法区(如 HotSpot 虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK 11 时期的 ZGC 收集器就不支持类卸载)。 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不在使用的类型
StringTable为什么要调整位置
JDK 7 中将 StringTable 放到了堆空间中。因为永久代的回收效率很低,在 Full GC 的时候才会触发。而 Full GC 是老年代的空间不足、永久代不足时才会触发。
这就导致 StringTable 回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
2.3.7 静态变量存放在那里?
-Xms200m -Xmx200m -XX:PermSize=300m -XX:MaxPermSize=300m -XX:+PrintGCDetails
public class TestStatic { private static byte[] arr = new byte[1024 1024 100]; public static void main(String[] args) { System.out.println(TestStatic.arr); } } |
---|
在JDK6、7、8中都是
静态引用对应的对象实体始终都存在堆空间
可以使用 jhsdb.ext,需要在 JDK 9 的时候才引入的
分析三个变量放在哪?本身放在哪?
public class StaticObject { static class 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 StaticObject.Test(); test.foo(); } } |
---|
staticObj 随着 Test 的类型信息存放在方法区,instanceObj 随着 Test 的对象实例存放在 Java 堆,localObject则是存放在 foo()方法栈帧的局部变量表中。
测试发现:三个对象的数据在内存中的地址都落在 Eden 区范围内,所以结论:只要是对象实例必然会在 Java 堆中分配。
接着,找到了一个引用该 staticObj 对象的地方,是在一个 java.lang.Class 的实例里,并且给出了这个实例的地址,通过Inspector查看该对象实例,可以清楚看到这确实是一个 java.lang.Class 类型的对象实例,里面有一个名为 staticObj 的实例字段:
从《Java虚拟机规范》所定义的概念模型来看,所有 Class 相关的信息都应该存放在方法区之中,但方法区该如何实现,《Java虚拟机规范》并未做出规定,这就成了一件允许不同虚拟机自己灵活把握的事情。JDK 7 及其以后版本的 HotSpot 虚拟机选择把静态变量与类型在 Java 语言一端的映射 Class 对象存放在一起,存储于 Java 堆之中,从我们的实验中也明确验证了这一点
存储位置不同,永久代物理是是堆的一部分,和新生代,老年代地址是连续的,而元空间属于本地内存;存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中。
2.3.8 方法区垃圾回收
有些人认为方法区(如 HotSpot 虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK 11 时期的 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 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
总结
常见面试题
百度 三面:
说一下 JVM 内存模型吧,有哪些区?分别干什么的?
蚂蚁金服:
Java 8 的内存分代改进 JVM 内存分哪几个区,每个区的作用是什么?
一面:JVM 内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个 Survivor 区?
二面:Eden 和 Survior 的比例分配
小米:
JVM 内存分区,为什么要有新生代和老年代
字节跳动:
二面:Java 的内存分区 二面:讲讲 JVM 运行时数据库区 什么时候对象会进入老年代?
京东:
JVM 的内存结构,Eden 和 Survivor 比例。 JVM 内存为什么要分成新生代,老年代,持久代。
新生代中为什么要分为 Eden 和 Survivor 。
天猫:
一面:JVM 内存模型以及分区,需要详细到每个区放什么。
一面:JVM 的内存模型,Java 8 做了什么改
拼多多:
JVM 内存分哪几个区,每个区的作用是什么?
美团:
Java 内存分配 JVM 的永久代中会发生垃圾回收吗?
一面:JVM 内存分区,为什么要有新生代和老年代?