首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >JVM揭秘之旅:打破性能瓶的终极指南(1)

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

原创
作者头像
半旧518
发布2025-07-03 19:05:46
发布2025-07-03 19:05:46
1520
举报
文章被收录于专栏:JVM揭秘之旅JVM揭秘之旅

专栏简介

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

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

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

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

什么是jvm

学习JVM有什么用

常见的JVM

以下JVM以Hotspot为准。

JVM的学习路线

2、程序计数器

程序计数器的作用

在物理上,程序计数器是通过寄存器实现的。

程序计数器的特点

线程私有。每个线程都有自己的程序计数器。不同线程抢cpu,抢到了,就根据程序计数器的地址,执行下一条代码。

不会存在内存溢出。

3、虚拟机栈

栈:先进后出。

虚拟机栈:线程运行时需要的内存空间。

栈帧:每个方法运行时需要的内存。

栈的演示

栈的问题辨析

1.垃圾回收不涉及栈内存。因为方法结束,栈帧的生命就结束了。

2.栈内存大,可以进行递归调用的层数多,但程序可执行线程越少(总内存不变,栈内存大,线程数少)

3.

详解:

对于基本数据类型,安全。

局部变量存在栈中,属于线程私有。

对于对象。

判断下列三个方法是否线程安全?

m1,显然安全。局部变量线程私有。

m2,不安全,形参是被传入的,其它线程也可有访问到sb。

m3,不安全。虽然sb是局部变量,但是被返回了,其它线程可以访问到。

栈内存溢出

1.栈帧过多。

实际一般是递归调用没有合理的递归终止条件。

有时候不是自己的代码有问题,而是没有正确引用第三方库。

比如转json时。

代码语言:java
复制
| import com.fasterxml.jackson.databind.ObjectMapper;
class Dept {  private String name; 
private Emp manager;   // 构造方法、getter和setter  
  public Dept(String name) {  this.name = name;  }   
  public String getName() {  return name;  }   
  public void setName(String name) {  this.name = name;  }   
  public Emp getManager() {  return manager;  }  
   public void setManager(Emp manager) {  this.manager = manager;  } }  
   class Emp {  private String name;  private Dept dept;   // 构造方法、getter和setter  
   public Emp(String name) {  this.name = name;  }  
    public String getName() {  return name;  }  
     public void setName(String name) {  this.name = name;  }  
      public Dept getDept() {  return dept;  }   
      public void setDept(Dept dept) {  this.dept = dept;  } }
        public class StackOverflowDemo {  
            public static void main(String[] args) throws Exception {  // 创建相互引用的对象  Dept dept = new Dept("研发部"); 
             Emp emp = new Emp("张三");    dept.setManager(emp);  
             emp.setDept(dept);    // 尝试序列化为JSON - 这将导致栈溢出
             ObjectMapper mapper = new ObjectMapper();  
             String json = mapper.writeValueAsString(dept);  System.out.println(json);  } } |

正确做法。

``

class Emp { // ... @JsonIgnore public Dept getDept() { return dept; } } |

```

2.栈帧过大导致内存溢出。一般不会出现。

线程运行诊断——cpu运行高

top命令定位到进程。

定位到线程。

H 表示以"线程模式"显示信息。默认 ps 只显示进程,加上 H 后会显示进程中的所有线程

-e 显示所有用户进程,包括其它用户进程。

-o 自定义输出格式

知道了线程编号,接下来。定位到代码中的线程名

先将线程id32665换算成16进制。

Jstack 进程id.

看到没,第8行代码。

线程死锁排查——程序运行很长时间无结果

nohub在后台运行代码。

本来应该输出结果,一直没有。可能是死锁。

上面运行代码,可以看到进程id 32752.

Jstack 32752.

报错信息很明显。

根据上面信息的代码行数定位下问题。

很明显,它们互锁了。

4、本地方法栈

本地方法栈(Native Method Stack)是 Java 虚拟机(JVM)运行时数据区的一个重要组成部分,它的主要作用是支持本地方法(Native Method)的执行。本地方法(Native Method)是指用Java以外的语言(通常是C或C++)编写并在Java程序中通过Java本地接口(JNI)调用的方法。这些方法允许Java程序突破JVM的限制,直接与操作系统或硬件交互。

例子。

5、堆

定义

堆内存溢出

举个栗子。

排查堆内存溢出问题时,可以使用`-xmx`,把堆内存设置小一些,这样容易尽早暴露问题。

jmap

看个栗子。

运行。jps。

在输出1,2,3时分别调用jmap,查看堆内存占用情况。

自己玩下吧。

jconsole

接下来演示下jconsole。很简单,控制台直接输下jconsole就ok。

选择进程,连接。

线程死锁,也可以用jconsole来排查。

Jvisualvm排查:垃圾回收后,内存占用依旧很高

输入命令,启动下jvisualvm。

点击堆dump,抓取当前堆快照。

查找大对象。

看到没,都是一些student对象。

student类里面的big属性,一个占1M+。一共200多个,占了200多兆。

定位到源代码。

6、方法区

定义

下面节选自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面试题

7、StringTable

String Tab有如下特性

image-20250623123606562
image-20250623123606562

一道经典面试题

image-20250623102248014
image-20250623102248014

为了搞清楚这个面试题。

常量池与串池的关系

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

看下面代码。

image-20250623102620663
image-20250623102620663

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

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

image-20250623103930153
image-20250623103930153

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

image-20250623114612804
image-20250623114612804

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

image-20250623110706302
image-20250623110706302

字符串变量的拼接

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

image-20250623105010150
image-20250623105010150

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

image-20250623110222628
image-20250623110222628

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

image-20250623105934832
image-20250623105934832

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

image-20250623110335435
image-20250623110335435

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

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

image-20250623110524983
image-20250623110524983

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

image-20250623111003848
image-20250623111003848

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

StringTable编译期优化

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

image-20250623111232251
image-20250623111232251

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

image-20250623111713000
image-20250623111713000

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

这是怎么做到的呢?

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

image-20250623115800378
image-20250623115800378

字符串延迟加载

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

image-20250623120556017
image-20250623120556017

借助下Memory这个插件工具。

image-20250623121121438
image-20250623121121438

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

image-20250623121339140
image-20250623121339140

StringTable_intern_1.8

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

image-20250623122228279
image-20250623122228279

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

image-20250623122517327
image-20250623122517327

接下来,猜猜下面结果?

image-20250623122746543
image-20250623122746543

结果如下。

image-20250623123414377
image-20250623123414377

StringTable_intern_1.6

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

image-20250623123711222
image-20250623123711222

面试题解答

现在再看面试题

image-20250623102248014
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
image-20250623130601048

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

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

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

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

jdk1.6环境。

image-20250623131425962
image-20250623131425962

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

在1.8环境。

image-20250623131632220
image-20250623131632220

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

看看官方文档。

image-20250623131927417
image-20250623131927417

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

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

image-20250623132203689
image-20250623132203689

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

image-20250623132253480
image-20250623132253480

StringTable的垃圾回收

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

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

参考下列demo。

image-20250623132930627
image-20250623132930627

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

垃圾回收相关信息。

image-20250625121718163
image-20250625121718163

StringTable相关信息。

image-20250625121227333
image-20250625121227333

代码改动下。

image-20250625122004522
image-20250625122004522

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

image-20250625122120169
image-20250625122120169

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

image-20250625122214945
image-20250625122214945

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

image-20250625122306514
image-20250625122306514

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

image-20250625122437910
image-20250625122437910

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

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

StringTable性能调优

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

调整桶个数

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

image-20250625123108934
image-20250625123108934

运行如下示例。

image-20250625123148267
image-20250625123148267

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

image-20250625123426769
image-20250625123426769

为何这么快呢?

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

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

image-20250625123717072
image-20250625123717072

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

image-20250625123915309
image-20250625123915309

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

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

考虑字符串对象是否入池

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

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

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

image-20250625124750867
image-20250625124750867

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

读取数据前。

image-20250625125115812
image-20250625125115812

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

image-20250625125447352
image-20250625125447352

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

image-20250625125601972
image-20250625125601972

内存占用显著下降。

8、直接内存

概念

image-20250702112518823
image-20250702112518823

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

代码语言:javascript
复制
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();
        }
    }
}

不使用。

代码语言:javascript
复制
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
image-20250702113147008

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

image-20250702113349511
image-20250702113349511

内存溢出

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

参考下面例子。

image-20250702113551396
image-20250702113551396

运行,溢出了。

image-20250702113627125
image-20250702113627125

释放原理

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

image-20250702113840466
image-20250702113840466

运行下。

image-20250702113814230
image-20250702113814230

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

image-20250702114024740
image-20250702114024740

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

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

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

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

image-20250702114411067
image-20250702114411067

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

image-20250702114553810
image-20250702114553810

回车。内存被回收了。

image-20250702114627541
image-20250702114627541

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

image-20250702115017142
image-20250702115017142
image-20250702114920683
image-20250702114920683

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

image-20250702115238210
image-20250702115238210

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

image-20250702115354747
image-20250702115354747

运行。

image-20250702115553491
image-20250702115553491

回车。

image-20250702115616750
image-20250702115616750

直接内存没有被回收。

怎么解决?

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

image-20250702115743343
image-20250702115743343

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 专栏简介
  • 7、StringTable
    • 一道经典面试题
    • 常量池与串池的关系
    • 字符串变量的拼接
    • StringTable编译期优化
    • 字符串延迟加载
    • StringTable_intern_1.8
    • StringTable_intern_1.6
    • 面试题解答
    • StringTable位置
    • StringTable的垃圾回收
    • StringTable性能调优
      • 调整桶个数
      • 考虑字符串对象是否入池
  • 8、直接内存
    • 概念
    • 基本使用
    • 内存溢出
    • 释放原理
    • 禁用显示垃圾回收对直接内存的影响
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档