JVM揭秘之旅:打破性能瓶的终极指南(3)

专栏简介

「为什么Java程序员必须啃透JVM?」
JVM是Java生态的“灵魂引擎”,但多数开发者仅停留在API调用层面。当面临频发GC卡顿诡异OOM崩溃线程死锁顽疾时,是否曾因底层原理的模糊而束手无策?本专栏将带您穿透技术迷雾,系统攻克JVM核心领域:

  • ⚙️ 硬核原理拆解:从字节码执行、类加载双亲委派,到G1/ZGC回收器设计,逐层剖析JVM的运作机制;
  • 🛠️ 调优实战手册:结合大厂案例,详解参数配置(如-XX:+HeapDumpOnOutOfMemoryError)、内存泄漏定位(MAT工具)、并发瓶颈破解;
  • 🚀 前沿技术追踪:涵盖元空间、JIT编译、协程(Loom项目)等新特性,提前掌握未来技术栈;
  • 💡 面试高频攻略:深度解析京东/华为等大厂JVM面试题(如“CMS与G1的权衡”“内存屏障作用”)6,8

适合读者
✅ 渴求突破CRUD的Java工程师
✅ 被性能问题困扰的架构师
✅ 备战P7/P8级技术面试的求职者

专栏承诺不用空洞理论堆砌,每篇均附可复现的代码案例及调优脚本。跟随专栏,您将获得从“被动救火”到“主动防御”的JVM掌控力!
1、方法区

定义

下面节选自jdk1.8的官方文档。方法区是所有java虚拟机线程共享的一个区域。存的都是跟类相关的信息。比如类构造器,类变量,方法代码,运行时常量池。

在虚拟机启动时被创建。逻辑上是堆的一个组成部分(具体的产商实现并不一定如此)。

下面是方法区在不同jdk版本实现时的一个示例。jdk1.6占用的是堆内存。jdk1.8,string table放在了堆中,其它则存储在操作系统的本地内存中。

方法区内存溢出

下列代码,定义了一个类加载器,可以动态加载字节码文件。

jdk1.6环境运行。指定最大元空间大小为8M暴露下问题。

在jdk1.8以后,元空间用的是操作系统的系统内存,物理内存很大,所有你不会观察到内存溢出的(是不是知道为啥要这么优化方法区的实现了?)。

同样,我们可以指定最大元空间大小为8M来演示下效果。

生产环境内存溢出案例

上面完全是我们自己创建了很多class对象。生产中会出现方法区内存溢出吗?

会。因为我们用很多框架。比如。

它们都用了cglib。动态加载字节码。

可以看看cglib的源代码。就是在运行时,动态生成字节码,完成动态类加载。

当然,jdk1.8以后,由于用的是系统内存,相对充裕很多。操作系统垃圾回收效率也比jvm高很多,因此,内存溢出情况少很多。

常量池

先解释下常量池。看下列代码。

javap反编译下。

Javap -v HelloWorld.class

输出结果。

类的基本信息,了解下即可。

虚拟机指令。

上面执行虚拟机指令时,`#2`究竟代表啥呢?

其实就是常量池中的静态变量。

是不是理解了,可以理解成这就是一个常量符合的定义字典。

StringTable面试题

2、StringTable

String Tab有如下特性

image-20250623123606562

一道经典面试题

image-20250623102248014

为了搞清楚这个面试题。

常量池与串池的关系

我们先从简单的开始。了解下常量池和串池的区别和联系。

看下面代码。

image-20250623102620663

可以看到,上面代码的编译结果。

常量池,在编译后,最初是存储在字节码文件中。在运行前,只是常量池中的符号(字面量)。并没有转换为java对象。如下图。

image-20250623103930153

常量池中的常量,只有在运行时执行到引用它的代码,常量才会变为一个Java对象。举例子来说。'ldc # 2’这个操作执行时,才会将a符号变为字符串对象"a"。

image-20250623114612804

在变为对象"a"时,会到串池StringTable中找下有没有这个对象(内部是一个hash表)。没有就加入(加入的是引用,不是实际对象,实际对象都在堆里哟,这个小细节,不理解可以跳过)。

image-20250623110706302

字符串变量的拼接

在上一小节基础上,再加一行代码。先运行编译,然后反编译。

image-20250623105010150

我们看看反编译后结果如下:
image-20250623110222628

补充说明下,上面的2号位置,4号位置,其实是指局部变量表中的slot编号。对应反编译代码如下。

image-20250623105934832

上面反编译结果,我们看到,最后调用了toString()方法。看看源码。

image-20250623110335435

原来转为了一个新的string对象。

那么,判断下,s3==s4的结果。

image-20250623110524983

很简单,肯定是false。因为我们已经知道,s4会创建一个全新的对象。跟s3不是一个对象呀。

image-20250623111003848

现在,你已经回答出来第一道面试题了。

StringTable编译期优化

接下来,二面开始。s5入场。

image-20250623111232251

反编译后的结果,简单粗暴。直接去常量池中把拼接好的"ab"找到了。

image-20250623111713000

那么,s3==s5?当然!都是一个对象呀。

这是怎么做到的呢?

因为javac在编译器就会优化。“a”和“b”都是常量,变不了。“a”+“b”只可能是“ab”。那编译期间当然可以直接给你一个“ab”。javac就是这么干的。

image-20250623115800378

字符串延迟加载

我们前面说过,字符串对象的生成,其实是一种懒惰模式。只有运行到对应代码,才会创建对象,之前只是一个字面量。为了证明这一点,我们可以看看下面的例子。

image-20250623120556017

借助下Memory这个插件工具。

image-20250623121121438

调试过程,可以看到每一步的对象数量。我们发现,每执行一步,对象增加一个(非相同对象情况下)。说明字符串对象是延迟加载的。

image-20250623121339140

StringTable_intern_1.8

String.intern() 是 Java 中用于**字符串驻留(String Interning)**的方法,其核心作用是:将字符串对象动态添加到 JVM 的字符串常量池(String Pool)中,并返回池中的唯一引用​,举个例子。

image-20250623122228279

而且,s.intern()操作,就是会被字符串对象放入串池。所以,s也==“ab”。

image-20250623122517327

接下来,猜猜下面结果?

image-20250623122746543

结果如下。

image-20250623123414377

StringTable_intern_1.6

jdk1.6的intern方法,规则有所不同,这里了解下。

image-20250623123711222

面试题解答

现在再看面试题

image-20250623102248014

s3== “a” + “b”.会被javac编译器进行优化,引用的是字符串池中的"ab"。

s4 是堆中创建的新对象。

因此,s3 == s 4返回false,s3==s4返回true。

s6 == s4.intern(),让s4字符串对象进入字符串池,但是因为已经有了"ab",入池失败。s4还是引用堆中对象。但是s4.intern()返回的结果是字符串池中的对象。因此,s3 == s6返回true。

x2是堆中创建的对象。

x1是串池中的常量。

x2.intern()对x2进行入池,入池失败。所以,x1 == x2返回false。

如果调换最后两行代码。x2成功入池。x1引用的就是池中的变量。所以,x1 == x2返回true。

如果调换最后两行代码,是jdk1.6版本,x2不会直接入池而是会拷贝一份,所以x1 == x2返回fasle。

到此为止,这一类面试题你已经打遍天下无敌手了。

StringTable位置

image-20250623130601048

jdk1.6,StringTable在永久代的方法区。

但是,StringTable存储的字符串,这很常用啊。永久代垃圾回收时机很晚,这会导致FullGC内存问题的。

因此,jdk1.7,StringTable被移到了堆中。

我们可以通过下面案例证明一下。

jdk1.6环境。

image-20250623131425962

永久代空间不足,说明字符串池存放在永久代中。

在1.8环境。

image-20250623131632220

没有出现我们想象中的堆内存不足。怎么回事?

看看官方文档。

image-20250623131927417

98%时间用在垃圾回收,但是只能回收掉少于2%的内存,说明你这已经到了癌症晚期,不可救药。不救了。

为了让我们正常演示,我们可以把这个开关关闭。

image-20250623132203689

再跑一次。看到堆空间不足的提示了吧。

image-20250623132253480

StringTable的垃圾回收

接下来,我们看看StringTable的垃圾回收机制。

当内存不足时,StringTable中,没有被引用的字符串常量,仍然会被垃圾回收。这可能和很多人想的并不一样。很多人以为字符串常量就是永久的,不会被回收。

参考下列demo。

image-20250623132930627

运行后。我们这里使用了-XX:+PrintStringTableStatistics-XX:PrinrGCDetails会打印一些统计信息,重点关注我截图部分

垃圾回收相关信息。

image-20250625121718163

StringTable相关信息。

image-20250625121227333

代码改动下。

image-20250625122004522

字符串对象数量变成了1854.

image-20250625122120169

j变成10000,存储多一些字符串。让他触发下垃圾回收。

image-20250625122214945

可以看到,只存了7000多个字符串常量。

image-20250625122306514

这是因为内存分配失败,触发了GC。

image-20250625122437910

因为我们代码中的字符串对象没有被引用,因此,很多可以被GC回收。

这也就证明了我们的结论。内存不足时,StringTable中,没有被引用的字符串常量,仍然会被垃圾回收。

StringTable性能调优

调优 StringTable主要涉及 减少哈希冲突、优化内存占用、提升字符串操作性能

调整桶个数

准备一个字典文件。包含48万个词。

image-20250625123108934

运行如下示例。

image-20250625123148267

只用了0.4s,真的很快啊。

image-20250625123426769

为何这么快呢?

这是因为我们使用了参数 -XX:StringTableSize=200000,StringTable足够大,哈希冲突碰撞少。

去掉这个参数运行。变慢了,看到默认桶大小时60013.

image-20250625123717072

你也可以显示把它改成最小值1009,测试下会不会更慢。

image-20250625123915309

可以看到慢了很多,因为StingTable底层是数组+链表,你需要在放一个新的串之前进行查找,有没有这个字符串。太小了就容易哈希冲突。查询慢。

总结:如果你的代码需要处理大量字符串,这些字符串会放到串池,可以适当增加StringTableSize的大小。

考虑字符串对象是否入池

为何字符串对象要入池?什么情况要入池。

比如据说Twitter要存储用户地址,如果全部存,要30G内存。但这些地址大部分重复的。因此显式调用intern入池,就可以将需要内存减少到几百兆。

下面我们用一个demo来展示下。48万个词循环读取10次。存到list中防止被垃圾回收。代码逻辑中,使用System.in.read()来控制其运行进度。

image-20250625124750867

运行,用jvisualvm来看数据。使用抽样器,实时对内存占用进行展示。

读取数据前。

image-20250625125115812

控制台输入回车,开始读取单词。字符串的内存占用大幅提升了。

image-20250625125447352

接下来,我们把程序代码稍微修改下。

image-20250625125601972

内存占用显著下降。

8、直接内存

概念

image-20250702112518823

NIO使用的其实就是直接内存。一个使用直接内存的例子。比不用的效率高很多

import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class DirectMemoryExample {
    public static void main(String[] args) {
        try (RandomAccessFile file = new RandomAccessFile("test.txt", "rw");
             FileChannel channel = file.getChannel()) {
            
            // 分配直接内存(1MB)
            ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024);
            
            // 写入数据到直接内存
            directBuffer.put("Hello, Direct Memory!".getBytes());
            directBuffer.flip(); // 切换为读模式
            
            // 将数据从直接内存写入文件
            channel.write(directBuffer);
            System.out.println("数据已通过直接内存写入文件");
            
            // 手动释放直接内存(重要!)
            if (directBuffer.isDirect()) {
                ((sun.nio.ch.DirectBuffer) directBuffer).cleaner().clean();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

不使用。

import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class HeapMemoryExample {
    public static void main(String[] args) {
        try (RandomAccessFile file = new RandomAccessFile("test.txt", "rw");
             FileChannel channel = file.getChannel()) {
            
            // 分配堆内存(1MB)
            ByteBuffer heapBuffer = ByteBuffer.allocate(1024 * 1024);
            
            // 写入数据到堆内存
            heapBuffer.put("Hello, Heap Memory!".getBytes());
            heapBuffer.flip(); // 切换为读模式
            
            // 将数据从堆内存写入文件(需经内核态拷贝)
            channel.write(heapBuffer);
            System.out.println("数据已通过堆内存写入文件");
            
            // 无需手动释放:JVM垃圾回收自动管理
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

基本使用

直接内存为何快?

不使用直接内存,数据造成了不必要的复制

image-20250702113147008

使用直接内存。java代码直接可以访问直接内存,少进行了一次数据复制。效率成倍增加。

image-20250702113349511

内存溢出

直接内存不受java程序管理,那它会不会造成内存溢出?

参考下面例子。

image-20250702113551396

运行,溢出了。

image-20250702113627125

释放原理

直接内存能否被正确回收?测试下。

image-20250702113840466

运行下。

image-20250702113814230

敲回车。垃圾回收。再敲回车。

image-20250702114024740

好像内存回收掉了。这是好消息,说明不会造成内存泄漏。

问题:那这跟GC有什么关系?不是说直接内存不归java程序管理?

实际上,这是因为jdk内部会调用一个unsafe类。而不是因为垃圾回收。一般不建议程序员自己使用unsafe类。

这里我们为了演示其工作流程,用它写个demo跑下。

image-20250702114411067

运行后,java内存占用到达2G

image-20250702114553810

回车。内存被回收了。

image-20250702114627541

我们现在直接看下DerectByteBuffer源码,验证下。

image-20250702115017142

image-20250702114920683

禁用显示垃圾回收对直接内存的影响

image-20250702115238210

这会有一个问题。看下面栗子。在JVM调优时,经常会为了避免full GC占用大量时间,对性能产生影响,禁用显示垃圾回收。这回对直接内存产生影响。

image-20250702115354747

运行。

image-20250702115553491

回车。

image-20250702115616750

直接内存没有被回收。

怎么解决?

很简单,你自己手工释放下。

image-20250702115743343

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

半旧518

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值