程序计数器

当前线程所执行的字节码的行号指示器,会记录下一条即将要执行命令的字节码指令的地址(元空间的地址)。

其实就是字节码的解释器工作的时候,通过改变程序计数器的值来决定下一条需要解释的字节码。

虚拟机栈

JVM主要和方法的调用和执行相关,它是线程私有的,每个线程都有他的虚拟机栈,虚拟机栈是由一个个 “栈帧”组成,每一个方法调用和执行,都是入栈和出栈的过程。而栈帧里面存储的具体有四部分。

操作数栈: JVM执行字节码指令的时候,很多操作比如加减和方法调用都在操作数栈中运行,比如c=a+b;先把a和b变量压入操作数栈,再执行iadd,最后将返回值c放入局部变量表。

局部变量表:存储方法的参数和局部变量。局部变量表的大小在编译期就确定了,运行时不会变。

动态链接:每个栈帧存有一个指针,这里主要是方法调用的时候,指针指向方法区中符号引用。这样会在运行的时候把符号引用转换成实际的内存地址,就能找到要执行的代码。

方法出口记录方法执行完以后,回到的位置。比如A方法调用了B方法,B方法执行完了以后会执行A方法中下一个执行的位置。

异常表这里是当使用try catch finally的时候,如果正常执行,try方法执行完,程序计数器会goto到下一个位置,如果触发异常会指向该异常类在元空间中的类信息的字节码地址。

本地方法栈

java底层很多本地方法,是C++写的,那么为了存放这部分使用本地方法所产生的局部变量、操作数栈、动态链接等,单独把他们放在了一块内存位置,是线程私有的。

这一块是java虚拟机内存管理中最大的一块区域,它是一块线程共享的区域,Java虚拟机启动的时候,将大部分实例对象、数组以及字符串常量池放入堆中,分配内存。

堆这里也是垃圾收集器管理的主要区域,也称为GC堆。堆内存通常分为三部分,新生代,老年代还有元空间,在jdk1.8之前元空间叫永久代。他们也有一些不同的地方,元空间存在本地内存,但是永久代是在堆内存中。

新生代:又分为Eden和survivor生存区,生存区又分两块from和to,我就拿垃圾回收的复制算法l举例,当进行新生代垃圾回收的时候,会将Eden区的死亡对象回收,存活对象会放入生存区的from区。此时to区是空闲的,再进行垃圾回收的时候,会将from区和Eden区的死亡对象回收,并把存活对象,放入to区,此时from区和Eden区都空了,再将to区变成from区,from区变to区。这里又涉及几种情况,第一种,from区放不下存活对象了,存活对象占用内存太大了,会将存活对象放入老年代。第二种情况,存活对象会每次垃圾回收移来移去,新生代有个晋升规则,当一个对象经历过一次垃圾回收,会在对象的对象头中的年龄区域,增加1,最大当年龄达到15次,就会触发进行规则,将该对象放入老年代。为什么是15次?因为刚刚说了,存放年龄的区域在对象头,它是4位字节,最大1111也就是15。

复制算法:将区域分两块,一块保持空闲,另外一块存放死亡对象,存活对象,空区域,先进行标记存活对象,然后将该区域其他内存变成空区域,同时复制存活对象到另外的一块区域,然后将这块区域全部清除。相当于进行了一次交换,交换过程中清理了垃圾对象。空间换时间的思想。

老年代:也会垃圾回收,当触发老年代垃圾回收的时候,死亡对象清除,存活对象存活。这里就涉及到几种垃圾回收算法了,我举例几种:

首先是标记清除,我们把内存区域表示成一块一块的,比如是16的格子吧,但是这些格子大小不一致,每个格子可能会有死亡对象,存活对象,空的区域。会标记存活对象的格子,其他位置全部变成空区域。存在什么问题?内存碎片!比如清除了死亡对象1,它的空间是1平方米,此时又来了新的存活对象,可能通过新生代晋升机制来的,也可能是生存区放不下的对象来的,来了以后发现有一个空位,但是这个空位不够放…会尝试将一些连续的空小块合并,看能不能放下,不能的话就oom了。

标记整理算法:和之前的标记清除差不多,但是解决了刚刚说的,有空位,但是放不下,清除以后他会进行一次整理,将所有存活对象放在最左边,剩下的空闲区域全是连续的在最右边,这样只有新来的对象小于该区域都能放下。这种缺点因为要整理,效率略低,时间换空间。

方法区

它是JVM规范的一个逻辑内存区域,它里面存放了类信息、静态变量、运行时常量池和JIT的代码。线程共享的一块区域,所以所有的线程都可以访问它的类信息。所以他会保持类加载的唯一性。多个线程加载同一个类只会加载一次。我来说一下方法区存放的具体信息。

首先是类的元数据,就是类的一些基本信息,比如类的全限定名,父类是谁,实现了什么接口,创建了什么方法(方法名,返回值,参数类型),类的属性字段的信息,像字段名,类型,访问修饰符。

第二个运行时常量池,这里就要讲一下静态常量池,静态常量池是编译类的class文件中就有的,比如string类型的a变量的abc,int类型,这些常量,包括一些符号引用。当 JVM 加载这个类时,会把.class文件里的 “静态常量池” 内容加载到方法区,形成 “运行时常量池”。运行时常量池支持动态加载,比如String的intern方法,会先判断运行时常量池里面有没有要加载的字符串,没有的话就加载到运行时常量池。

第三个静态变量,这个就是static修饰的一些基本常量和引用类型的类放在方法区。对象实例还是在堆内存中。

第四个JIT编译运行后的代码,频繁调用的 “热点方法”,JIT 会把它编译成本地机器码,这些代码也存在方法区相关的区域。

方法区像java中的接口,那接口要有实现类,元空间和永久代就像是方法区的实现类,在jdk1.7以及之前是使用永久代来实现方法区,但是有个缺点,它在 Java 堆里,大小要手动设,容易因为空间不够抛 OOM;而 Java 8 之后改成了 “元空间”,它用的是本地内存,默认没有固定大小(看本地内存剩余量),也更灵活,能减少之前永久代 OOM 的问题。

如何保证类的唯一性

首先,类的 “唯一性” 其实有个前提:一个类是否唯一,由它的 “全限定名” 和 “加载它的类加载器” 共同决定。也就是说,即使两个类的全限定名完全相同(比如都是com.example.User),如果是由不同的类加载器加载的,JVM 也会认为它们是两个不同的类。这是保证唯一性的基础逻辑。

具体到实现上,核心是双亲委派模型。当一个类加载器(比如应用类加载器)收到类加载请求时,它不会先自己去加载,而是先委托给它的父类加载器(比如扩展类加载器),父类加载器再委托给更上层的启动类加载器。只有当所有父类加载器都无法加载这个类(比如不在它们的加载路径中),当前类加载器才会尝试自己加载。

这种机制的好处是:同一个类最终会被最上层能加载它的类加载器加载。比如java.lang.String,不管哪个类加载器收到请求,最终都会委托给启动类加载器加载,这样就保证了全程序中String类的唯一性,不会出现多个类加载器各自加载一个String类的情况,避免了核心类被篡改或重复加载。

另外,多线程并发加载时的同步控制也很重要。当多个线程同时请求加载同一个类时,类加载器会通过同步锁(比如synchronized)保证只有一个线程能进入 “真正的加载流程”,其他线程会被阻塞。等第一个线程完成加载后,其他线程会直接获取已加载的类信息,而不是重复加载。这就避免了并发场景下的重复加载问题。

最后,类一旦被加载、连接、初始化后,就会在方法区中保存它的元数据,后续再有加载请求时,JVM 会直接检查方法区中是否已存在该类(根据 “全限定名 + 类加载器” 判断),如果存在就直接返回,不会重新执行加载流程。

总结一下的话,我觉得是通过 “双亲委派模型保证加载器的统一”、“同步机制控制并发加载”、“方法区存储已加载类信息” 这几点,共同保证了类加载的唯一性。

Reference

reference(通常翻译为 “引用”)是存储在栈内存中的一个指向堆内存中对象的 “地址标识”,它是程序操作对象的 “桥梁”。reference 本身是一个存储在栈上的变量,它不直接存储对象的具体数据,而是存储了能找到堆中对象的信息。比如当我User user1 = new User()的时候,new User()会在堆中创建一个User对象,而user1 就是reference,存储在栈上,它会指向对象实例数据,比如具体的属性值。堆中的对象实例数据并非孤立存在,内部会包含一个类型指针,这个指针会指向方法区中该对象所属类的元信息。所以reference会间接通过实例的类型指针关联方法区的类元数据。指向对象实例数据这里分两种对象访问方式,一种是句柄,reference会指向句柄池(堆中)中的对象实例数据的指针,这个指针会指向实例池中对象的实例信息。第二种是直接指针,reference直接指向实例信息。

内存分配和回收机制

image.png

Eden、S0和S1(Survivor区)都属于新生代,Tenured区属于老年代,最下面属于永久代或者元空间。

复制垃圾回收算法:大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。发起MinorGC以后survivor区就派上用场了,JVM先回收Eden区无引用的对象,会将Eden区中存活的对象复制一个到其中一个survivor(from)区。那另一个survivor(To)区就是空的,等到下次MinorGC以后,会将Eden区和已使用的survivor(from)区中存活的对象复制到这个未使用的survivor(To)区,这样刚刚使用的survivor区和Eden又会清空。同时from和to交换,如果新生代容纳不下了,触发Minorgc把大对象移动到老年代。

survivor区不可能一直将数据这样移来移去吧,设置了年龄晋升机制,每次MinorGC以后存活的对象年龄+1,当年龄达到阈值,会将该对象放到老年代。!(如果MinorGC以后存活的对象发现survivor区放不下它,它就会直接存放在老年代)。如果老年代内存也满了触发一次MinorGC(默认打开fullgcbeforeminorgc减轻FullGC的压力)和FullGC,回收新生代所有的垃圾对象和老年代无引用对象和元空间不再使用的类信息。接着就会检查Full GC以后的老年代空间是否充足,充足就在老年代分配新的对象,不充足JVM 会判定 “内存分配失败”,此时无法再为新对象分配内存,最终抛出 java.lang.OutOfMemoryError: Java heap space

老年代满无法回收的情况:核心原因就是老年代中存放了太多存活对象

1、内存泄露,对象被无意识的引用,导致一直存活,积累过大,存放在老年代。

2、大对象创建频繁:没复用的数组、大字符串、快速直接进入老年代占满空间。

3、设置晋升年龄过小,导致新生代的对象快速的进入老年代。

可达分析算法

是死亡对象的判断方法,通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

img

五种引用

强引用

就是平时我们平常写的,垃圾回收器宁愿抛出OOM,也不会强行回收它

1
String a = new String("aaa")

软引用

如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。

1
2
3
// 软引用
String str = new String("abc");
SoftReference<String> softReference = new SoftReference<String>(str);

弱引用

弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。一般使用软引用不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。同样有引用队列

1
2
3
String str = new String("abc");
WeakReference<String> weakReference = new WeakReference<>(str);
str = null; //str变成软引用,可以被收集

虚引用

没有生命周期的概念,只要被垃圾回收器发现是虚引用,就会直接回收,并将它进入关联的引用队列、

1
2
3
4
String str = new String("abc");
ReferenceQueue queue = new ReferenceQueue();
// 创建虚引用,要求必须与一个引用队列关联
PhantomReference pr = new PhantomReference(str, queue);

终结器引用(弃用)

在jdk9之前,Object类有finalize()方法,当对象准备被回收的时候才可能使用,一个对象的finalize最多执行一次。

当对象满足垃圾回收条件时,GC 的处理步骤如下:

  1. GC 通过可达性分析判定对象为垃圾,准备回收。
  2. 检查对象是否重写了 finalize()
    1. 若未重写:直接回收对象内存。
    2. 若已重写:将对象放入一个名为 F-Queue 的队列中,由 JVM 的一个低优先级线程(Finalizer 线程)逐一执行队列中对象的 finalize() 方法。
  3. finalize() 执行完毕后,GC 会再次检查对象是否可达:
    1. 若仍不可达:回收其内存。
    2. 若已 “复活”(如在 finalize() 中被其他可达对象引用):对象重新变为存活状态,本次不回收。

已经被弃用了,首先是执行时机不确定,第二要分配额外的线程回收,第三如果在finalize中重新和GCROOT建立链接,因为finalize只能执行一次,所以就会无法回收导致内存泄露。我们可以在try catch finally中释放资源也可以直接调用clone() destroy()手动释放资源。

垃圾回收算法

标记-清除

假设内存是一个线段,对象是内存中的线段。标记出不用被回收的对象,清除所有需要被回收的对象,这样线段会变成一段一段的,因为部分对象被清除了,并不会合并。我们称为会产生不连续的内存碎片。

标记-整理

和标记清除类似,但是会整理,这样会消除内存碎片。

img

复制算法

分成两块,将存活对象放在另外一块内存。

img

垃圾回收器

垃圾回收器分新生代收集器、老年代收集器、混合

新生代收集器:Serial、ParNew、Parallel Scavenge

老年代收集器:Serial Old、CMS、Parallel Old

新生代和老年代:G1、ZGC、Shenandoah

致远垃圾回收器用的G1

img

一般垃圾回收器需要搭配使用

单线程版: Serial New(复制算法)+Serial Old(标记整理) JDK5之前

JDK8:

ParNew(复制算法 并行)+CMS(标记清除)——适合电商网站、高并发服务器 4-8G推荐

Parallel Scavenge(复制算法 并行 吞吐量优先)——适合大数据、科学计算 4G以下推荐

G1 JDK9以后默认

新生代:复制

老年代:标记整理

适用企业级项目,中大规模Web项目、大堆内存的应用

8G以上可以用G1

zgc:适用极低停顿时间的大内存应用、内存密集型数据库、金融交易系统、云服务

几百G以上用ZG

G1 理解V1.0

G1的混合型的垃圾回收器,它最主要的改变是将堆内存分成了一块一块的Region,每个块Region代表一个角色,角色就是之前的Eden、survivor、Old、此外还加了一个巨大对象存储的区域Humongous(大于Region内存的一半,会占用一个Region,如果占用超过一个Region,占用连续的N个Region区),其余则是空白区也就是未分配的内存。当我们创建对象,对象会先在Region中Eden区占用内存,当一个Eden区满了,但是发现处理该Eden区花费的时间小于我设置的young GC垃圾回收的卡顿时间STW,不会进行垃圾回收,而会找一个空白区域创建一个新的Eden区存放新对象,当发现处理Eden区的时间达到了你设置的时间。会young GC这个时候会触发STW,然后通过可达性分析算法找到直接被GCROOTS引用的对象,同时还有一些间接引用的对象,但是这些间接引用的对象如果堆内存逐个遍历会非常消耗cpu性能,于是G1使用了RSet(记忆集),他会记录每个Region中哪个对象被其他的Old区对象所引用,E区经常需要分析和回收所以不会被记录。接着将Eden和from Survivor的存活对象复制到新的to Survivor中,其中如果有存活对象达到了设置的晋升年龄或者Survivor区存储不下的对象,会放在老年代的Region中。最终Eden区域为空。

那么为了避免老年代空间的耗尽,G1还有Mixed GC混合回收,当老年代超过整堆比默认45%,会触发混合回收。它会进行年轻代的回收,同时也会回收部分被标记的老年代分区。在此之前会进行全局并发标记,首先是初始标记,标记处GCROOTS直接引用的对象,防止引用对象的变更,会触发STW,但是GCROOTS直接引用对象不多,STW时间也就很短,并发标记不会STW,它是业务线程和回收线程一起执行,为了防止回收过程中引用的变化,G1基于开始时候的初始内存快照进行标记,在标记过程中通过写屏障记录业务线程对对象引用的改变。等并发标记结束后,在最终标记阶段STW,根据对象引用变化对并发标记结果调整,完成标记阶段。接着来到回收阶段同样的复制法,将当前Region中存活的对象拷贝到另一个Region中,然后将当前的Region清空。

老年代内存占到堆内存的45%,会触发并发标记和混合收集。当判断出混合回收的速度赶不上对象分配的速度,老年代 Region 会被快速占满,并发收集相当于失败,会触发Full GC,G1的目标就是尽量不要FullGC。

初始化快照如何记录,通过指针,在并发标记之前会通过一个初始指针标记出Region中已使用内存的top指针位置,在业务线程运行的时候,会创建对象内存变大,top指针会移动,当触发并发标记会再次记录top指针的位置,而两个top指针中间部分就是并发标记的初始快照,有些Region会被多次并发标记,因为垃圾回收器会优先回收垃圾占比更多的Region,有些占比非常少的就会放弃回收。

如何记录对象引用变化的正确性,新创建的对象,G1统一认为是存活的,等下次并发周期再判断标记处理,引用变化会用写屏障记录,每个业务线程会有一个队列,记录改变引用的原来的对象,当队列满了,放入一个全局队列中,在最终标记阶段就会将全局队列中的对象和引用对象都标记存活状态。

三色标记法

垃圾回收器如何标记存活对象的。通过三色标记法,首先垃圾回收器会有一个位图1为黑色存活对象并且引用的子对象全被扫描完毕,0为垃圾对象,从 GC Roots 出发,直接引用的对象被标记为灰色(这些对象是可达的,但它们引用的子对象还未处理),当并发标记的时候,分析灰色对象的引用子对象,对引用的子对象进行处理,白色的标记为灰色加入队列等待处理,灰色和黑色的忽略。当灰色对象所有的引用子对象扫描处理完毕,就把灰色对象标记为黑色。最终灰色队列为空,所有可达对象被标记成黑色,剩余白色对象都是不可达的垃圾对象。

G1内存模型

垃圾收集过程

Full GC

老年代内存占到堆内存的45%,会触发并发标记和混合收集。当判断出老年代的垃圾产生速度大于了垃圾回收的速度,并发收集相当于失败,会触发Full GC,G1的目标就是尽量不要FullGC。

类加载

Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件的。

系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。

加载

通过全类名获取类的二进制字节流,将字节流存储的静态存储结构转换为方法区的运行时数据结构,在内存中生成对应类的class文件,作为方法区数据的访问入口。加载阶段主要是由类加载器完成的。类的字节码载入方法区。

验证

验证是链接阶段的第一步,这一阶段的目的是保证class文件的字节流包含的信息符合java虚拟机的规范,保证信息不会危害到虚拟机自身的安全。如果代码已经被反复使用和验证过了,可以通过Xverify:none参数考虑关闭大部分类的验证措施。验证主要会验证文件格式、元数据、字节码和符号引用。

准备

正式为类变量分配内存并设置类变量初始值,对于基本数据类型,像int 初始化为0,boolean为false,reference 为 null。这里的初始值是默认的值,准备阶段一般都是给变量赋值默认值,但是当变量使用final修饰,就会在准备阶段给变量赋自己设置的值。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

初始化

初始化阶段是执行初始化方法 <clinit> ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。

类卸载

卸载类即该类的 Class 对象被 GC。

卸载类需要满足 3 个要求:

  1. 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
  2. 该类没有在其他任何地方被引用
  3. 该类的类加载器的实例已被 GC

类加载器

JVM 中内置了三个重要的 ClassLoader

**BootstrapClassLoader****(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jarresources.jarcharsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。

**ExtensionClassLoader****(扩展类加载器):主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。

**AppClassLoader****(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

img

每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,如果获取到 ClassLoadernull的话,那么该类加载器的父类加载器是 BootstrapClassLoader

自定义类加载器

要继承ClassLoader抽象类,

ClassLoader 类有两个关键的方法:

  • protected Class loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。name 为类的二进制名称,resolve 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。
  • protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。

如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

双亲委派模型

ClassLoader 类使用委托模型来搜索类和资源。每个 ClassLoader 实例都有一个相关的父类加载器。需要查找类或资源时,ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。 虚拟机中被称为 "bootstrap class loader"的内置类加载器本身没有父类加载器,但是可以作为 ClassLoader 实例的父类加载器。

从上面的介绍可以看出:

  • ClassLoader 类使用委托模型来搜索类和资源。
  • 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
  • ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。

双亲委派模型的执行流程

实现逻辑在classLoader类中的loadClass方法 首先会检查类是否被加载过,已经被加载过的类会直接返回,如果没有被加载过,当父类加载器不为空,则用父类的loadClass加载该类,如果为空说明,父类的启动类加载器,就用启动类加载器加载类,如果父类加载器无法加载的时候,调用findClass方法加载该类,所以自定义类加载器的时候,又不想打破双亲委派模型,用户需要选择重写finalClass方法。

好处

可以避免类的重复加载和防止核心API被篡改,因为Java判断两个类是否相同,不仅仅只是看类是否来源于同一个class文件被同一个虚拟机加载,还要看加载它们的加载器是否是同一个,如果有一个条件不满足就不相同。

打破双亲委派模型

需要重写loadClass方法,类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制

!线程上下文类加载器

拿 Spring 这个例子来说,当 Spring 需要加载业务类的时候,它不是用自己的类加载器,而是用当前线程的上下文类加载器。还记得我上面说的吗?每个 Web 应用都会创建一个单独的 WebAppClassLoader,并在启动 Web 应用的线程里设置线程线程上下文类加载器为 WebAppClassLoader。这样就可以让高层的类加载器(SharedClassLoader)借助子类加载器( WebAppClassLoader)来加载业务类,破坏了 Java 的类加载委托机制,让应用逆向使用类加载器。

线程上下文类加载器的原理是将一个类加载器保存在线程私有数据里,跟线程绑定,然后在需要的时候取出来使用。这个类加载器通常是由应用程序或者容器(如 Tomcat)设置的。

Java.lang.Thread 中的getContextClassLoader()setContextClassLoader(ClassLoader cl)分别用来获取和设置线程的上下文类加载器。如果没有通过setContextClassLoader(ClassLoader cl)进行设置的话,线程将继承其父线程的上下文类加载器。

运行期优化 (JIT)

非逃逸对象:对象的生命周期仅限于当前方法或线程,不会被外部(其他方法、线程、全局变量等)访问。比如:方法内部创建的对象,仅在方法内使用,未被返回、未被传递给外部变量。JIT会对非逃逸对象进行优化,比如栈上分配减少GC压力、同步消除,判断出并为逃逸出某线程,加锁了,编译的时候会取消锁。标量替换,比如某个类的属性,当前只使用到了他的基本类型属性,编译的到时候就会直接把基本属性放入栈中,不创建该类,减少开销。

逃逸分析是 JIT(即时编译器)的重要优化手段,默认开启(可通过-XX:-DoEscapeAnalysis关闭)。它通过分析字节码和对象引用关系,动态判断对象是否逃逸,进而触发优化。这一技术显著提升了 Java 程序的性能,尤其对频繁创建短期局部对象的场景(如循环内创建临时对象)效果明显 —— 减少堆内存占用和 GC 频率,同时消除不必要的同步开销。逃逸分析的本质是通过追踪对象的引用范围,为编译器提供优化依据,最终目标是减少内存使用、降低 GC 压力、提升程序执行效率。

双层检查锁