下面文章的笔记整理。只记录重点内容,建议配合文章原文一起观看。
JavaGuide文章链接:https://javaguide.cn/java/concurrent/threadlocal.html
内存分布
线程私有(生命周期与线程一致):
- 程序计数器:线程中记录代码执行顺序、当前位置、下一步执行的位置。(线程私有)
- 虚拟机栈(先进后出):记录方法的调用顺序,每调用一个方法都会生成一个栈帧压入栈中,执行结束后弹出。栈帧中记录方法的信息(局部变量表、操作数栈、动态链接、方法返回地址)。如果调用方法过多超过栈的深度则会抛出StackOverFlowError。
- 本地方法栈(先进后出):和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务(非Java方法)。
线程共享(生命周期与虚拟机一致):
-
堆:内存最大,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。细分为新生代(Eden、S0、S1)和老年代(Old)、永久代(JDK1.7,1.8时永久代被元空间取代)。
创建的对象大部分情况会分配在Eden区,经过一次新生代垃圾回收(Minor GC)后还存活,就会被分配到S0或S1,并且年龄增加1。当年龄增加到阈值(默认15)时,就会晋升到老年代中。
-
方法区:记录类的信息。当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。永久代和元空间是方法区的具体实现。
HotSpot团队在JDK1.6就计划废弃永久代,到JDK1.8正式废弃永久代改用本地内存的元空间。原因是永久代存在固定大小上限,无法进行调整;而元空间使用本地内存虽然也有可能会溢出但概率更小。
-
运行时常量池:存储加载到内存的Class的常量池(存放编译期生成的各种字面量和符号引用),动态性也使得运行区间生成的常量也会放置在此处。
-
字符串常量池:存放字符串,避免重复创建。是一个固定大小的HashTable,保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。
JDK1.7之前字符串常量池和静态变量放在永久代(方法区)中,JDK1.7中把字符串常量池和静态变量移动到了堆中。原因是在永久代中(方法区)只有整堆收集(Full GC)才会对字符串进行回收。放到堆中可以提高字符串的回收效率。 -
直接内存:直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。
JNI是Java Native Interface的缩写,用于在Java程序中调用和被调用C、C++代码,并且允许Java代码与原生代码(Native Code)进行交互。
对象
对象的创建
对象的创建过程:类加载检查 -> 分配内存 -> 初始化零值 -> 设置对象头 -> 执行init方法
- 类加载检查:JVM检查是否已加载&解析&初始化过引用的类,如果没有则先执行相应的类加载过程。(具体加载步骤往下看“类加载过程”)
- 分配内存:新生对象分配内存(内存大小在类加载是已确定)。分配方式由 Java 堆是否规整决定(GC的算法处理后堆的内存情况(是否有内存碎片))。内存分配的两种方式 :指针碰撞(内存规整)、空闲列表(存在内存碎片)。
内存分配并发:频繁并发的创建对象中,需要保证线程安全。
1. CAS+失败重试
2. TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。 - 初始化零值:将分配到的内存空间都初始化为零值(不包括对象头)。例如int为0,boolean为false。
- 设置对象头:对象的其他必要的设置。这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 执行init方法:以上最初始的对象已创建完毕,接着执行init方法完成程序对对象的自定义设置或赋值。例如int=2。
对象的访问定位
通过 句柄 或 直接指针 访问创建在堆上的对象,访问方式由虚拟机决定。HotSpot虚拟机使用 直接指针 来访问对象。
- 句柄:划分出一块内存来作为句柄池,对象引用指向的就是对应的句柄池,其中存储对象实例数据与对象类型数据各自的具体地址信息。好处是对象发生改变是只需要修改句柄的指针。
- 直接指针:对象引用直接指向对象实例数据(包含对象类型数据地址)。好处是直接访问对象,节省了一次指针定位时间开销。
内存分配和回收原则
虚拟机采用了分代收集的思想来管理内存,把堆分成不同区域,不同的区域采用不同的垃圾回收算法。
- 对象优先在Eden区分配:大多数情况下,新创建的对象会在Eden区分配内存,当Eden区内存不足时,虚拟机就会发起一次Minor GC。
- 大对象直接进入老年代:将需要大量连续空间的对象直接放入老年代中,能较少新生代的垃圾回收频率和成本。
- 长期存活的对象将进入老年代:当对象从eden区移到S区里并存活年龄达到阈值(默认15)时,将会晋升到老年代。
- 空间分配担保:为了确保进行Minor GC后老年代有足够容纳新生代所有对象的存储空间。JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区。
疑问:当老年代空间不足时,是执行Major GC 还是 Full GC?目前从通过网上的查询结果来看,都是用了Full GC对老年代进行清理,那Major GC究竟什么时候会执行呢?如果都用Full GC清理,Major GC存在的意义在哪呢?因为不确定所以上下文中对于老年代的清理都只写了Full GC。
堆结构说明
内存大小:老年代 > Eden区 > S1 = S2区(默认:Eden:S1:S2 = 8:1:1)
- 为什么Eden区比S1和S2区大?因为大部分创建的对象在第一轮Minor GC时就会被回收,而当Eden区内存满了就会进行GC,如果Eden区太小,会频繁进行GC。
- 为什么需要S区?避免Eden区GC后,对象频繁进行老年代使得老年代频繁进行Full GC(比较耗时)。S区相当于一层缓存,只要在S区年龄到达晋升阈值时,才会进入老年代。
- 为什么需要两个S区?为了解决内存碎片的问题,因为因为对象回收后,对象间会存在大量的内存碎片空间,这部分空间不足以存放一个完整的对象。所以当某个S区满了之后,就会把对象移到另一个S区中,从而清除内存碎片。因此两个S区都偏小,且其中一个S区一直是空置的。
对象内存变化过程
- 创建新对象,判断对象需要的内存大小:
a. Eden区放不下:
ⅰ. 判断老年代如果空间不充足,则先进行Full GC
ⅱ. 进行Minor GC
ⅲ. 如果还放不下,分配到老年代中,放得下同b一致。
b. 正常大小:在新生代Eden区分配内存,age为0。 - 当发生新生代垃圾收集(Minor GC)且未被回收的对象
a. S区放不下:直接分配到老年代中。
b. 转移到S区,age加1。 - 当age年龄到达阈值(默认15)时,分配到老年代中。
死亡对象判断方法
- 引用计数法:给对象添加引用计数器,记录实时被引用的次数,为0时死亡。简单高效,但如果存在循环引用时会导致无法GC。
- 可达性分析法:以“GC Roots”对象为起点,形成对象的引用链,“GC Roots”不可达的对象都能需要被回收。HotSpot虚拟机使用这种判断方法。
下面是可作为“GC Roots”的对象:- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
- JNI(Java Native Interface)引用的对象
引用类型
强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱),一般只使用前两个。
- 强引用(StrongReference):大部分引用都是强引用,即使内存不足虚拟机也不会对这些具有强引用的对象进行回收。
- 软引用(SoftReference):内存不足时才会进行回收。
SoftReference personSoft = new SoftReference(new Person(20));
personSoft.get(); // 通过get()去直接使用 - 弱引用(WeakReference):比软引用更短的生命周期,被垃圾回收器发现就会被回收。
WeakReference personWeak = new WeakReference(new Person(20));
personWeak.get(); // 通过get()去直接使用 - 虚引用(PhantomReference):虚引用必须和引用队列(ReferenceQueue)联合使用。设置虚引用关联的唯一目的,就是在这个对象被收集器回收的时候收到一个系统通知或者后续添加进一步处理。
- 废弃字符串:字符串常量池中未被引用的字符串,如果这时发生内存回收的话而且有必要的话就会被系统清理出常量池了。
- 无用的类:需要满足以下条件就可以被回收
a. 堆中无此类的实例对象(都被回收)
b. 加载此类的ClassLoader已被回收
c. 此类的Class未被引用,且没被反射。
垃圾收集算法
- 标记-清除算法(Mark-and-Sweep):标记不需要回收的对象后,统一回收没被标记的对象。(因为可达性分析法只能找到不需要回收的,把取其余找不到的都清除)存在问题:两个过程效率都不高,且会产生大量不连续的内存碎片。
- 复制算法(Copying):把内存分为大小相同的两块,每次只使用其中一块。当其中一块内存不足时,把还存活的对象移到另外一块上,然后再清理原来的内存。新生代S区就是使用这种方式。存在问题:内存有一半是空置的,且存活对象多性能会变差。
- 标记-整理算法(Mark-and-Compact):标记不需要回收的对象后,把这些对象移动到一端,并清除另一端的数据。老年代使用这种方式。
- 分代收集算法:根据对象生命周期分成不同的区域,采用不同的处理方式。堆的新生代和老年代。
垃圾收集器
收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
JDK 默认垃圾收集器:
- JDK 8:Parallel Scavenge(新生代)+ Parallel Old(老年代)
- JDK 9 ~ JDK20: G1
类文件结构
具体内容请看原文:https://javaguide.cn/java/jvm/class-file-structure.html
类加载过程
类的整个生命周期可以简单概括为 7 个阶段::加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。类的加载指 加载->连接->初始化 这三步,初始化完成后才能运行使用。
- 加载:类加载器通过全类名获取定义此类的二进制字节流,在内存中生成代表该类的Class对象。
- 连接:
- 验证:连接阶段的第一步,确保Class文件的字节流符合约束要求。
- 准备:为类变量(static修饰的变量)分配内存,设置初始值,例:代码为static int i = 2,此时赋初始值i为0。如果类变量被final修饰,那么会直接赋值而非设置初始值,例:static final int i = 2,此时直接赋值i为2。
- 解析:虚拟机将常量池内的符号引用替换为直接引用的过程。(因为类加载前并不知道引用的类、方法、变量在内存中的位置,所以我们只能用符号引用来表示,也能增加代码的可读性)
// 符号引用 String str = "aaa"; System.out.println("str=" + str); // 直接引用 System.out.println("str=" + "aaa");
- 初始化:执行初始化方法 ()方法(编译后自动生成)的过程,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
- 类卸载:该类的Class被GC。
类加载器
- 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
- 每个 Java 类都有一个引用指向加载它的 ClassLoader。
- 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。
类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。
JVM 中内置了三个重要的 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 包和类。
加载规则:用到才会去加载类,加载后放到ClassLoader中,使用前会先判断是否已加载。
ClassLoader通过getParent()获取到的父ClassLoader为null,那么该类通过BootstrapClassLoader加载的。因为BootstrapClassLoader是C++实现的,Java中没对应的类,所以为null。
继承ClassLoader并重写loadClass或findClass方法可以自定义类加载器,重写loadClass会破坏双亲委派模型。
双亲委派模型
执行流程:
[1类加载]类加载时调用自己的类加载器的loadClass()
- [1类加载]检查该类是否已经加载过
- [1类加载]类还未被加载,判断父类的加载器是否为空
- [1类加载]父类的加载器不为空,通过父类的loadClass()加载该类(注:该父类也会继续向上委托)
- [1类加载]父类的加载器为空,调用启动类加载器(BootstrapClassLoader)来加载该类
- [1类加载]当父类加载器无法加载时,则用自己类的findClass()来加载该类
- [1类加载]类还未被加载,判断父类的加载器是否为空
- [2类连接]类已加载,进入类连接阶段
类加载时,会先让其父类加载器进行加载,其父类也会向上委托直到父类加载器为空,使用启动类加载器(BootstrapClassLoader)进行加载,即所有委托最终都会传到BootstrapClassLoader。父类加载器加载失败后,该类才会使用自己的类加载器进行加载。如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException
异常。
好处:避免类被重复加载,也保证了Java核心API不被篡改。
打破双亲委派:自定义类加载器继承ClassLoader,并重写loadClass()进行自定义即可打破。