一、java内存区域
java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙。
-
运行时数据区:java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。
- 程序计数器(Program Counter,PC)
- 是JVM中每个线程私有的一块很小的内存区域,存储的是当前线程即将执行的下一条指令的地址。(如果执行的是本地方法,值为空Undefined)
- 程序计数器是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
- 字节码解释执行:当JVM执行Java程序时,执行引擎(解释器/即时编译器)会通过程序计数器来逐条读取方法区运行时常量池中的字节码指令并执行。当线程执行一个方法时,它的程序计数器会指向当前正在执行的字节码指令的地址。每当执行完一条指令后,程序计数器就会更新为下一条指令的地址。当线程暂停执行(比如,等待I/O操作完成)时,PC会保留当前执行的位置,以便线程恢复执行时能够从上次暂停的地方继续执行。
- 补充:
- 符号引用:
- 符号引用是用于编译期的引用,它是一种用于描述目标对象的符号。
- 在编译期间,Java源代码中通过类名、方法名等符号来引用其他类、接口、方法和字段。符号引用在编译后的字节码中以CONSTANT Class、CONSTANT Fieldref、CONSTANT Methodref等常量的形式存在。
- 直接引用:
直接引用是运行时的引用,它直接指向对象在内存中的地址。
直接引用是对象的内存地址,用于在运行时直接访问对象。 -
虚方法:当子类重写了父类中的方法时,子类的方法就成为了虚方法。
-
类加载过程中的解析阶段:JVM会将常量池中的符号引用转换为直接引用。如类、字段、方法(静态方法、父类方法、私有方法、构造方法和final方法等非虚方法),类加载时的动态链接结果是确定的。
栈帧中的动态链接:对于虚方法,如普通的方法重写,它们的目标方法在运行时才能确定,因此它们的符号引用需要在运行时转换为直接引用,栈帧中的动态链接结果是不确定的,因为它依赖于对象的运行时类型。动态链接的过程体现了Java的多态性,它允许在运行时根据对象的实际类型来调用相应的方法
- 符号引用:
- java虚拟机栈
- 线程私有,生命周期与线程相同。虚拟机栈描述的是java方法执行的线程内存模型:每个方法被执行的时候,java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 局部变量表:
- 在编译后会在字节码中分配一个局部变量表,用于存储方法参数和方法内定义的局部变量。
- 以变量槽为最小单位,一个Slot可以存放32位的数据类型,局部变量表是一个数组,数组中的每个元素可以存储一个字节码类型的值。
- 局部变量表所需的内存空间在编译期间完成分配。
- 操作数栈:在方法执行过程中,用来存储方法执行过程中的临时数据(字节码指令的操作数和计算结果)。
- 动态连接:是Java虚拟机中的一种链接技术,用于在运行时将符号引用转换为直接引用。Java中的方法调用通常是通过符号引用来表示的,而在运行时,虚拟机需要将这些符号引用转换为实际内存地址或方法句柄,这个过程就是动态链接。动态链接的实现可以通过方法区的Class常量池来完成,其中保存了符号引用和直接引用之间的映射关系。
- 方法出口:每个方法在字节码中都有一个方法出口,用于标识方法执行结束时应该跳转到的位置。
- 局部变量表:
- 如果线程请求的栈深度大于虚拟机允许的深度(StackOveflowError);当栈扩展时无法申请足够的内存-OutOfMemoryError
- 线程私有,生命周期与线程相同。虚拟机栈描述的是java方法执行的线程内存模型:每个方法被执行的时候,java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 本地方法栈
- 本地方法栈是为虚拟机使用到的本地方法服务
- 存在OOM/StackOverflow
- java堆
- 虚拟机管理的内存中最大的一块区域,被所有线程共享,在虚拟机启动时创建,该区域唯一的目的就是存放对象实例。几乎所有对象的实例以及数组都在堆上分配(逃逸分析技术、栈上分配、标量替换)。java堆是垃圾收集器管理的内存区域,也成为GC堆。
- 逃逸分析技术:JVM中的一种优化技术,用于分析对象的作用域,确定对象是否可能被外部方法或线程访问。
- 栈上分配:对于某些对象,特别是方法内部的局部变量,若它们不会逃逸出方法或线程的范围,那么将这些对象在栈上分配可以提高性能和减少垃圾收集的开销。栈上分配的对象随着栈帧的销毁而释放,无需垃圾收集器介入。
- 标量替换:将对象分解为其各个成员变量,将这些成员变量作为独立的标量(即基本数据类型或者引用类型)进行分配。若程序只需要访问对象的某个成员变量,就不需要加载整个对象,而只需要加载对应的标量即可。这样做可以减少内存占用和垃圾收集的压力。标量替换可以视作栈上分配的一种特例,因为它要求对象不会逃逸出方法范围。
- 从内存分配的角度看,所有线程共享的java堆中也可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配的效率。
- TLAB是JVM在创建线程时,为每个线程在堆的Eden区划分出的一块独立空间。这个空间是线程私有的,只允许当前线程在其中进行内存分配。由于每个线程都有自己的TLAB,因此在进行内存分配时不需要加锁,从而大大提高了分配效率。
- 虽然TLAB是线程私有的,但其他线程仍然可以访问其中的对象。只是在内存分配这个动作上,TLAB是线程独享的。
- java堆可以处于物理上不连续的内存空间中,当在逻辑上是连续的。
- 可通过参数(-Xms、-Xmx)扩展,存在OOM
- 虚拟机管理的内存中最大的一块区域,被所有线程共享,在虚拟机启动时创建,该区域唯一的目的就是存放对象实例。几乎所有对象的实例以及数组都在堆上分配(逃逸分析技术、栈上分配、标量替换)。java堆是垃圾收集器管理的内存区域,也成为GC堆。
- 方法区
- 各个线程共享的内存区域,用来存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的机器码等数据。
- 1.7及以前:永久代实现方法区;1.8:本地内存的元空间实现方法区。(JDK7的HotSpot将原本放在永久代的字符串常量池、静态变量移至java堆中)
-
JDK 6 中的字符串常量池
-
存储内容:在 JDK 6 中,字符串常量池存储的是 **字符串对象本身**。当使用字符串字面量(如 `String s = "abc"`)时,字符串对象会被直接存储到方法区的字符串常量池中。
-
‘intern()` 方法:如果调用 `String.intern()`,并且常量池中不存在该字符串,则会在常量池中创建一个新的字符串对象,并返回该对象的引用。
-
内存位置:字符串常量池位于方法区的永久代(PermGen)。永久代是一个独立的内存区域,用于存储类的元数据、常量池等。永久代内存大小固定,容易导致 `OutOfMemoryError: PermGen space`,且垃圾回收效率较低。
-
-
JDK 7 中的字符串常量池
-
存储内容:在 JDK 7 中,字符串常量池存储的是 **字符串对象的引用**,而不是对象本身。字符串对象实际存储在 Java 堆中,常量池中仅保存对这些对象的引用。
-
`intern()` 方法:如果调用 `String.intern()`,并且常量池中不存在该字符串,则会将堆中的字符串对象的引用放入常量池中。
-
内存位置:字符串常量池被移动到了 Java 堆中。堆是 Java 中主要的内存区域,用于存储所有对象实例。堆内存可动态扩展,垃圾回收机制更高效,减少了内存溢出的风险。
-
- 方法区无法满足新的内存分配需求时,将抛出OOM
- 运行时常量池
- 是方法区的一部分
- Class文件(也就是.java文件编译后生成的字节码.class文件)中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用。这部分内容将在类加载后进入方法区的运行时常量池中存放。
- 直接内存
- 程序计数器(Program Counter,PC)
- 对象的创建
- 类加载检查
- 内存分配
- 指针碰撞
- 空闲列表
- 初始化零值
- 对象头设置
- 执行init方法进行初始化
- 对象的内存布局
- 对象头
- mark word:存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等)
- 类型指针:确定该对象是哪个类的实例
- 实例数据:对象真正存储的有效信息
- 对齐填充:任何对象的大小都必须是8字节的整数倍(提升内存利用率、减少内存碎片)
- 对象头
- 对象的访问定位:java程序会通过栈上的reference来操作堆上的具体对象
- 使用句柄:堆中划分一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含对象示例数据(指向堆)和类型数据(指向方法区)各自具体的的地址信息;对象移动的时候只用改变句柄中的实例数据指针,而reference本身不需要修改;
- 直接指针:reference中存储的直接就是对象地址(hotSpot主要使用)。少一次指针定位的开销,访问速度快;
二、垃圾收集器与内存分配策略
java运行时数据区中,程序计数器、虚拟机栈、本地方法栈这三个区域随线程而生,随线程而灭;而java堆和方法区有着显著的不确定性,这部分内存的分配和回收是动态的。
- 判断对象存活
- 引用计数法
- 可达性分析算法:从Java堆中所有的根对象(GC Roots)开始,遍历它们的引用关系,如果一个对象没有任何引用指向它,那么这个对象就是不可达的。
- 在虚拟机栈(栈帧这的本地变量表)中引用的对象
- 在方法区静态属性引用的变量
- 在方法区中常量引用的对象
- 在本地方法中中JNI引用的对象
- java虚拟机内部的引用,基本数据类对于的Class对象,一些常驻的异常对象
- 所有被synchronized持有的对象
- 引用
- 强引用,只要强引用关系存在,垃圾收集器就不会回收被引用的对象
public static final Object STRONG_REF = new Object();
- 软引用,系统将要发生内存溢出前,会把这些对象列进回收范围之内进行第二次回收
- 弱引用,垃圾收集器开始工作,就会回收掉(ThreadLocal避免内存泄露)
- 虚引用,只为在这个对象被回收时收到一个系统通知
- 强引用,只要强引用关系存在,垃圾收集器就不会回收被引用的对象
- 两次标记回收
- 可达性分析发现没有GC Roots相连接的引用链,进行第一次标记
- 筛选finalize()是否重写或被执行(被重写且未被执行过)
- 加入F-Queue等待虚拟机创建线程执行;
- 若finalize()重新建立引用,该对象就逃逸了
- 注:任何一个对象的finalize()方法只会被调用一次
- 回收
- 方法区的回收
- 垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型
- 判断类型是否不再使用(同时满足以下三条件)
- 该类所有的实例都已经被回收,在java堆中不存在该类及其任何子类的实例
- 加载该类的类加载器已经被回收
- 该类对应的Class对象没有在任何地方被引用
- 判断类型是否不再使用(同时满足以下三条件)
- 垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型
- 垃圾收集算法
- 分代收集算法(将java堆分为新生代和老年代)(当前商业虚拟机均采用此算法)
- 新生代收集:Minor GC/Young Gc
- 老年代:Major GC/Old GC
- 整堆收集:Full GC
- 标记-清除算法
- 大量标记和大量清除效率低
- 产生不连续得到内存碎片
- 标记-复制算法
- 对象首先分配在Eden
- 空间不足,出发Minor GC,Eden和from存活的对象复制到to中,存活的对象年龄加一并且交换from和to(HotSpot默认Eden和Survivor的大小比例8:2)
- 当Survivor空间不足以容纳一次Minor GC之后存活的对象,这些对象通过分配担保机制直接进入老年代
- 标记-整理算法
- 其中移动存活对象需要暂停用户线程STW
- 分代收集算法(将java堆分为新生代和老年代)(当前商业虚拟机均采用此算法)
- 垃圾收集器
- Serial
- ParNew
- Parallel Scavenge
- Serial Old
- Parallel Old
- CMS
- G1(Garbage First)
- ZGC
- Shenandoah
- 内存分配
- 自动内存管理最根本的目标:自动给对象分配内存以及自动回收分配给对象的内存。对象的内存分配,从概念上讲,应该都是在堆上分配(实际上也有可能经过即时编译被拆散为标量类型并间接地在栈上分配)。在经典分代的设计下,新生对象通常会分配至新生代,少数情况可能直接分配在老年代。
- 对象优先在Eden分配:当Eden区没有足够的空间进行分配时,将发起Minor GC;
- 大对象直接进入老年代:
- 长期存活的对象进入老年代:超过年龄阈值(-XX:MaxTenuringThreshold = 15),晋升至老年代
- 动态对象年龄判断:如果在Survivor空间中低于或等于某年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到年龄阈值。
- 空间分配担保:在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure)。-XX:HandlePromotionFailure=true代表允许担保失败;-XX:HandlePromotionFailure=false代表不允许担保失败。如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者 -XX:HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次Full GC。(避免频繁地触发垃圾回收,从而提高程序的运行效率)
三、类文件结构
四、类加载机制
虚拟机把描述一个类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成被java虚拟机使用的Java类型。这就是虚拟机的类加载机制。
Class文件由类加载器加载后,在JVM中将形成一份描述Class结构的元信息对象,通过该元信息可以获知Class的结构信息:如构造函数,属性和方法等,Java允许用户借由这个Class相关的元信息间接调用Class对象的功能。
- 类加载的时机
- 类的生命周期:加载、验证、准备、解析、初始化、使用和卸载,其中验证、准备、解析统称为连接
- 虚拟机没有对什么时候进行类的加载有强制约束,但是对于初始化阶段,虚拟机规范则是严格规定了以下情况必须立即对类进行初始化(加载、验证、准备自然得在初始化之前完成):
- 遇到new、getstatic、putstatic和invokestatic这四条字节码指令时,如果类没有进行过初始化,则需要触发其初始化(初始化自然存在类的加载)。这四条指令最常见的场景:使用new关键字实例化对象、获取或设置一个类的静态字段(被final修饰的除外)和使用一个类的静态方法时。
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果没有对类进行过初始化,则需先触发初始化。
- 当初始化类的时候,发现其父类还没有进行初始化,需先触发父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个类。
- 当一个接口定义了JDK8新加入的默认方法,如果有这个接口的实现类发生初始化,那该接口要在其之前被初始化。
- 类的加载过程
- 加载(Loading):
- java虚拟机需要完成以下三件事
- 通过一个类的全限定名获取定义该类的二进制字节流。
- 将字节流代表的静态存储结构转换为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
- 一个非数组类型的加载阶段,既可以使用虚拟机内置的启动类加载器来完成,也可以由用户自定义的类加载器去完成(即重写一个类加载器的loadClass方法)。对于数组类而言,情况有所不一样,数组类本身不通过类加载器创建,而是由虚拟机直接在内存中动态构建。数组的元素类型最终还是靠类加载器来完成加载。
- 加载阶段结束后,虚拟机外部的二进制字节流就存储在方法区中。在java堆中实例化一个Class类对象作为访问方法区的外部接口。
- java虚拟机需要完成以下三件事
- 验证:验证是连接阶段的第一步,目的是保证Class文件的字节流包含的信息符合当前虚拟机的要求,保证输入的字节流能正确被解析并存储于方法区。
- 文件格式验证、元数据校验、字节码校验、符号引用校验
- 准备:正式为类中定义的变量(被static修饰的变量)分配内存并设置类变量初始值的阶段,这些内存在方法区(逻辑概念上)进行分配(JDK7及以后,类变量会随着Class对象一起存放在Java堆中)。还有,这里所说的初始值通常情况下是指数据类型的零值。
- 静态变量在什么时候赋值?
若被final修饰,那么在准备阶段就会对这个静态变量赋值为定义的值。
如果没有被final修饰,那么在准备阶段会赋零值,在初始化阶段真正赋为定义的值。
- 静态变量在什么时候赋值?
- 解析:虚拟机将常量池内的符号引用替换为直接引用的过程。
- 类或接口的解析
- 字段解析
- 方法解析
- 接口方法解析
- 初始化:初始化阶段是类加载过程的最后一步。在前面的类加载过程中,除了在加载阶段可以自动定义加载器参与类的加载过程外,其余的动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java代码。在准备阶段,变量已经被赋值为系统要求的零值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源。或者说初始化阶段是执行类构造器方法的过程。
- 加载(Loading):
- 类加载器:把类加载阶段的通过一个类的全限定名获取此类的二进制字节流这个动作放到Java虚拟机外部实现,以便让开发人员自己决定如何获取所需要的类,实现这个动作的代码称为“类加载器”。类加载器由加载、验证、准备、解析、初始化组成。
- 类与类加载器:对于任意一个类,都需要加载它的类加载器和这个类本身一同确定其所在虚拟机的唯一性。通俗地说,比较两个类是否相等,只有在相同的类加载器的前提下才有意义,否则,即使这两个类来自于同一个Class文件,被同一个虚拟机加载,只要类加载器不一样,这两个类就不可能相等。
- 双亲委派模型:从虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器,是虚拟机的一部分;另一种是其他的类加载器,独立于虚拟机之外,而且全都继承于抽象类java.lang.ClassLoader。
从开发人员的角度来看,绝大部分java程序都会使用到以下3种系统提供的类加载器:- 启动类加载器:这个类负责将放在<JAVA_HOME>\lib目录下的并且被虚拟机识别的(按照文件名识别,名字不符合的类库即使放在lib目录下也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被java程序直接引用。
- 扩展类加载器:它负责加载<JAVA_HOME>\lib\ext目录下的所有类库,开发者可以直接使用拓展类加载器.
- 应用程序类加载器:它负责加载用户类路径(ClassPath)下所指定的类库,开发者可以直接使用。如果程序中没有自定义自己的类加载器,一般情况下这个就是程序默认的类加载器。
- 双亲委派的过程过程是:如果一个类加载器收到类加载请求,首先不会自己尝试加载这个类而是委托给自己的父类去加载,父类又委托给自己的父类。因此所有的类加载都会委托给顶层的父类,即Bootstrap Classloader进行加载,然后父类自己无法完成这个加载请求,子加载器才会尝试自己去加载。使用双亲委派模型,Java类随着它的加载器一起具备了一种带有优先级的层次关系,通过这种层次模型,可以避免类的重复加载,也可以避免核心类被不同的类加载器加载到内存中造成冲突和混乱,从而保证了Java核心库的安全。
- 破坏双亲委派模型
- 因为双亲委派过程都是在loadClass方法中实现的,那么想要破坏这种机制,那么就自定义一个类加载器,重写其中的loadClass方法,使其不进行双亲委派即可。
- SPI
- Tomcat