JVM (1):内存区域划分、类加载机制
1 内存区域划分
-
程序计数器:当前线程所执行的字节码的行号指示器,记录当前线程执行的位置,线程切换后能恢复到正确的执行位置
-
虚拟机栈:每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用、方法出口等信息。
-
本地方法栈:与虚拟机栈类似,区别是虚拟机栈执行java方法,本地方法站执行native方法。
-
堆:用于存放新创建的对象 (几乎所有对象都在这里分配内存),当申请不到空间时会抛出 OutOfMemoryError。
-
方法区(元空间、永久代):主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
-
直接内存:
[! question] 永久代替换成元空间
- 永久代有一个 JVM 本身设置的固定大小上限,无法进行调整(也就是受到 JVM 内存的限制),而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小
- 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由
MaxPermSize
控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低
字符串创建:
String创建字符串的几种方式,以及在内存中的情况(JAVA)_string[] str = new string[3]能创建字符串吗-CSDN博客
1.1 线程私有
1.1.1 程序计数器
程序计数器(Program Counter Register) 是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
1.1.2 虚拟机栈
虚拟机栈描述的是Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame) 用于存储局部变量表、操作栈、动态链接、方法出口等信息。
动态链接 主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接 。
1.1.3 本地方法栈
虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法是一个原生态方法,方法对应的实现不是在当前文件,而是在用其他语言(如C和C++)实现的文件中。标识符native可以与所有其它的java标识符连用,但是abstract除外。这是因为native暗示这些方法是有实现体的,只不过这些实现体是非java的,但是abstract却显然的指明这些方法无实现体。
Java学习之——理解Native关键字 - 知乎 (zhihu.com)
1.2 线程共享
java6及之前字符串常量池才在方法区(永久代)吧,之后字符串常量池在堆(新生代/老年代)
1.2.1 堆空间
堆空间是JVM中用于存储对象实例的区域,它通常被划分为新生代和老年代两个主要部分,其中新生代又包括Eden区和两个Survivor区。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。堆内存被分为三部分:
-
新生代内存(Young Generation),新生代包括Eden区、两个Survivor区S0和S1
-
老生代(Old Generation)
-
永久代(Permanent Generation)
JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。
年龄为0-15?因为记录年龄的区域在对象头中,这个区域的大小通常是 4 位。这 4 位可以表示的最大二进制数字是 1111,即十进制的 15。因此,对象的年龄被限制为 0 到 15。
分配策略:1.对象优先在Eden区分配 2.大对象直接进入老年代 3.长期存活的对象将进入老年代 4.动态对象年龄判定 5.空间分配担保等
字符串常量池:字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
1.2.2 方法区
当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
运行时常量池:Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。
运行时常量池
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。
字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。
常量池表会在类加载后存放到方法区的运行时常量池中。
运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError
错误。
字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
方法区内容
用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
-
类信息:包括类的结构信息、类的访问修饰符、父类与接口等信息。
-
常量池:存储类和接口中的常量,包括字面值常量、符号引用,以及运行时常量池。
-
静态变量:存储类的静态变量,这些变量在类初始化的时候被赋值。
-
方法字节码:存储类的方法字节码,即编译后的代码。
-
符号引用:存储类和方法的符号引用,是一种直接引用不同于直接引用的引用类型。
-
运行时常量池:存储着在类文件中的常量池数据,在类加载后在方法区生成该运行时常量池。
-
常量池缓存:用于提升类加载的效率,将常用的常量缓存起来方便使用。
1.2.3 直接内存
直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError
错误出现。
2 内存泄露和内存溢出
内存泄露:内存泄漏是指程序在运行过程中不再使用的对象仍然被引用,而无法被垃圾收集器回收,从而导致可用内存逐渐减少。
内存泄露常见原因:
-
静态集合:使用静态数据结构(如HashMap或ArrayList)存储对象,且未清理。
-
事件监听:未取消对事件源的监听,导致对象持续被引用。
-
线程:未停止的线程可能持有对象引用,无法被回收。
内存溢出:内存溢出是指Java虚拟机(JVM)在申请内存时,无法找到足够的内存,最终引发OutOfMemoryError。这通常发生在堆内存不足以存放新创建的对象时。
内存溢出常见原因:
-
大量对象创建:程序中不断创建大量对象,超出JVM堆的限制。
-
持久引用:大型数据结构(如缓存、集合等)长时间持有对象引用,导致内存累积。
-
递归调用:深度递归导致栈溢出。
2.1 内存溢出情况
-
堆内存溢出:代码中存在大对象分配,或者发生了内存泄露
-
栈溢出:不断进行递归调用,没有退出条件。
StackOverFlowError
-
元空间溢出:出现这个异常的问题的原因是系统的代码非常多或引用的第三方包非常多或者通过动态代码生成类加载等方法,导致元空间的内存占用很大。
-
直接内存溢出:在使用ByteBuffer中的allocateDirect()的时候会用到,很多javaNIO(像netty)的框架中被封装为其他的方法,出现该问题时会抛出java.lang.OutOfMemoryError: Direct buffer memory异常。
3 对象创建
-
类加载检查
:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。 -
分配内存
:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。 -
初始化零值
:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。 -
设置对象头
:初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。 -
执行 init 方法
:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,init
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行init
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
4 对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头包括两部分信息:
-
标记字段(Mark Word)
:用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄
、锁状态标志
、线程持有的锁、偏向线程 ID、偏向时间戳等等。 -
类型指针(Klass Word):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
[!note] 对象头
Hotspot 虚拟机的对象头( Object Header ) 分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码GC( HashCode ) 、GC 分代年龄( Generational Age ) 等,这部分数据的长度在32 位和64 位的虚拟机中分别为32 个和64 个Bits,官方称它为“Mark Word",它是实现轻量级锁和偏向锁的关键另外一部分用千存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用千存储数组长度。
如在32 位的HotSpot 虚拟机中对象未被锁定的状态下, Mark Word 的32 个Bits 空间中的25Bits 用千存储对象哈希码(HashCode ) , 4B心用千存储对象分代年龄, 2Bits 用千存储锁标志位,lBit 固定为0.
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
5 类加载过程
[!note]
- 通过类加载器执行类加载(双亲委派模型)
- 验证类的字节流信息
- 为类变量分配内存
- 执行初始化
5.1 类的生命周期
类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段::加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。
5.2 类加载过程
-
加载
:加载通过 类加载器 完成(加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定),通过类的全限定名(包名 + 类名),获取到该类的.class文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构,在内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口 -
连接
:验证
:确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证准备
: 为类中的静态字段分配内存,并设置默认的初始值
,比如int类型初始值是0。被final修饰的static字段不会设置,因为final在编译的时候就分配了解析
:解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
-
初始化
:执行类的构造器方法,包括静态字段赋值
的动作,以及执行类定义中的静态初始化块内的逻辑(初始化阶段是执行初始化方法<clinit> ()
方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。) -
使用
:使用类或者创建对象 -
卸载
:如果有下面的情况,类就会被卸载:1. 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。2. 加载该类的ClassLoader已经被回收(该类的类加载器的实例已被 GC)。 3. 类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。( 该类没有在其他任何地方被引用)
[!note] 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段
-
这时候进行内存分配的仅包括类变量( Class Variables ,即静态变量,被
static
关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。 -
从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。
6 类加载器
类加载器是一个负责加载类的对象。ClassLoader
是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。每个 Java 类都有一个引用指向加载它的 ClassLoader
。
类加载器的主要作用就是加载 Java 类的字节码( .class
文件)到 JVM 中(在内存中生成一个代表该类的 Class
对象)。
public abstract class ClassLoader { |
-
BootstrapClassLoader
(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库(%JAVA_HOME%/lib
目录下的rt.jar
、resources.jar
、charsets.jar
等 jar 包和类)以及被-Xbootclasspath
参数指定的路径下的所有类。 -
ExtensionClassLoader
(扩展类加载器):主要负责加载%JRE_HOME%/lib/ext
目录下的 jar 包和类以及被java.ext.dirs
系统变量所指定的路径下的所有类。 -
AppClassLoader
(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
6.1 双亲委派模型
双亲委派机制(Parent Delegation Mechanism)是 Java 中的一种类加载机制。在 Java 中,类加载器负责加载类的字节码并创建对应的 Class 对象。双亲委派机制的核心思想是:当一个类加载器收到类加载请求时,它会先将该请求委派给它的父类加载器去尝试加载。只有当父级加载器无法加载该类时,才会尝试自行加载。
这个机制的作用在于保证类的加载是从上到下的,即从启动类加载器开始,逐级向下传递,直到找到所需的类或者无法加载。这样可以避免类的重复加载,提高了类加载的效率和安全性。例如,如果一个类已经被父类加载器加载过,那么子类加载器就不会再次加载它,从而避免了类的冲突和不一致性。
类加载器虽然只用于实现类的加载动作,但是对于任意一个类,都需要由加载它的类加载器和这个类本身共同确立其在Java虚拟机中的唯一性。
总之,双亲委派机制是 Java 类加载器中的一项重要特性,它确保了类的加载顺序和一致性,使得 Java 程序能够正常运行并保持良好的安全性。
6.1.1 加载过程
-
在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
-
类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()
方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器BootstrapClassLoader
中。 -
只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的
findClass()
方法来加载类)。 -
如果子类加载器也无法加载这个类,那么它会抛出一个
ClassNotFoundException
异常。
6.1.2 双亲委派模型好处
-
避免类的重复加载
-
保护程序安全,防止核心API被随意篡改
- 自定义类:java.lang.String (没用)
- 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang开头的类)
-
保证安全性:由于Java核心库被启动类加载器加载,而启动类加载器只加载信任的类路径中的类,这样可以防止不可信的类假冒核心类,增强了系统的安全性。例如,恶意代码无法自定义一个java.lang.System类并加载到JVM中,因为这个请求会被委托给启动类加载器,而启动类加载器只会加载标准的Java库中的类。
-
保证类的唯一性:通过委托机制,确保了所有加载请求都会传递到启动类加载器,避免了不同类加载器重复加载相同类的情况,保证了Java核心类库的统一性,也防止了用户自定义类覆盖核心类库的可能。
-
支持隔离和层次划分:双亲委派模型支持不同层次的类加载器服务于不同的类加载需求,如应用程序类加载器加载用户代码,扩展类加载器加载扩展框架,启动类加载器加载核心库。这种层次化的划分有助于实现沙箱安全机制,保证了各个层级类加载器的职责清晰,也便于维护和扩展。
6.1.3 打破双亲委派模型
自定义加载器的话,需要继承 ClassLoader
。如果我们不想打破双亲委派模型,就重写 ClassLoader
类中的 findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass()
方法。
为什么是重写 loadClass()
方法打破双亲委派模型呢?双亲委派模型的执行流程已经解释了:
类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()
方法来加载类)。
重写 loadClass()
方法之后,我们就可以改变传统双亲委派模型的执行流程。例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。
Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader
来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。
6.1.4 需要打破双亲委派模型场景
同一个类:类的路径名、类加载器
-
重写String类
-
自定义类加载器:容器Tomcat
-
一个web容器部署两个或者多个应用程序,不同的应用程序,可能会依赖同一个第三方类库的不同版本,还要能保证每一个应用程序的类库都是独立、相互隔离的效果。
-
Tomcat中可以部署多个web项目,为了保证每个web项目互相独立,所以不能都由AppClassLoader加载,所以自定义了类加载器WebappClassLoader,WebappClassLoader继承自URLClassLoader,重写了findClass和loadClass,并且WebappClassLoader的父类加载器设置为AppClassLoader。 WebappClassLoader.loadClass中会先在缓存中查看类是否加载过,没有加载,就交给ExtClassLoader,ExtClassLoader再交给BootstrapClassLoader加载;都加载不了,才自己加载;自己也加载不了,就遵循原始的双亲委派,交由AppClassLoader递归加载。
单纯依靠自定义类加载器没办法满足某些场景的要求,例如,有些情况下,高层的类加载器需要加载低层的加载器才能加载的类。
详谈双亲委派机制(面试常问)[通俗易懂]-腾讯云开发者社区-腾讯云 (tencent.com)
java中双亲委派机制(+总结) - luwanglin - 博客园 (cnblogs.com)
7 参考
某团面试:如果线上遇到了OOM,你该如何排查?如何解决?哪些方案?-CSDN博客
Java内存区域详解(重点) | JavaGuide
类加载过程详解 | JavaGuide