「为什么Java程序员必须啃透JVM?」 JVM是Java生态的“灵魂引擎”,但多数开发者仅停留在API调用层面。当面临频发GC卡顿、诡异OOM崩溃或线程死锁顽疾时,是否曾因底层原理的模糊而束手无策?本专栏将带您穿透技术迷雾,系统攻克JVM核心领域:
-XX:+HeapDumpOnOutOfMemoryError)、内存泄漏定位(MAT工具)、并发瓶颈破解;适合读者: ✅ 渴求突破CRUD的Java工程师 ✅ 被性能问题困扰的架构师 ✅ 备战P7/P8级技术面试的求职者
专栏承诺:不用空洞理论堆砌,每篇均附可复现的代码案例及调优脚本。跟随专栏,您将获得从“被动救火”到“主动防御”的JVM掌控力!
什么是jvm


学习JVM有什么用

常见的JVM

以下JVM以Hotspot为准。
JVM的学习路线

2、程序计数器
程序计数器的作用


在物理上,程序计数器是通过寄存器实现的。
程序计数器的特点
线程私有。每个线程都有自己的程序计数器。不同线程抢cpu,抢到了,就根据程序计数器的地址,执行下一条代码。

不会存在内存溢出。
3、虚拟机栈
栈


栈:先进后出。
虚拟机栈:线程运行时需要的内存空间。
栈帧:每个方法运行时需要的内存。

栈的演示

栈的问题辨析

1.垃圾回收不涉及栈内存。因为方法结束,栈帧的生命就结束了。
2.栈内存大,可以进行递归调用的层数多,但程序可执行线程越少(总内存不变,栈内存大,线程数少)
3.

详解:
对于基本数据类型,安全。
局部变量存在栈中,属于线程私有。

对于对象。
判断下列三个方法是否线程安全?

m1,显然安全。局部变量线程私有。
m2,不安全,形参是被传入的,其它线程也可有访问到sb。
m3,不安全。虽然sb是局部变量,但是被返回了,其它线程可以访问到。
栈内存溢出
1.栈帧过多。
实际一般是递归调用没有合理的递归终止条件。


有时候不是自己的代码有问题,而是没有正确引用第三方库。
比如转json时。
| 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面试题

String Tab有如下特性


为了搞清楚这个面试题。
我们先从简单的开始。了解下常量池和串池的区别和联系。
看下面代码。

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

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

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

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

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

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

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

原来转为了一个新的string对象。
那么,判断下,s3==s4的结果。

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

现在,你已经回答出来第一道面试题了。
接下来,二面开始。s5入场。

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

那么,s3==s5?当然!都是一个对象呀。
这是怎么做到的呢?
因为javac在编译器就会优化。“a”和“b”都是常量,变不了。“a”+“b”只可能是“ab”。那编译期间当然可以直接给你一个“ab”。javac就是这么干的。

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

借助下Memory这个插件工具。

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

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

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

接下来,猜猜下面结果?

结果如下。

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

现在再看面试题

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。
到此为止,这一类面试题你已经打遍天下无敌手了。

jdk1.6,StringTable在永久代的方法区。
但是,StringTable存储的字符串,这很常用啊。永久代垃圾回收时机很晚,这会导致FullGC内存问题的。
因此,jdk1.7,StringTable被移到了堆中。
我们可以通过下面案例证明一下。
jdk1.6环境。

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

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

98%时间用在垃圾回收,但是只能回收掉少于2%的内存,说明你这已经到了癌症晚期,不可救药。不救了。
为了让我们正常演示,我们可以把这个开关关闭。

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

接下来,我们看看StringTable的垃圾回收机制。
当内存不足时,StringTable中,没有被引用的字符串常量,仍然会被垃圾回收。这可能和很多人想的并不一样。很多人以为字符串常量就是永久的,不会被回收。
参考下列demo。

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

StringTable相关信息。

代码改动下。

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

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

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

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

因为我们代码中的字符串对象没有被引用,因此,很多可以被GC回收。
这也就证明了我们的结论。内存不足时,StringTable中,没有被引用的字符串常量,仍然会被垃圾回收。
调优 StringTable主要涉及 减少哈希冲突、优化内存占用、提升字符串操作性能。
准备一个字典文件。包含48万个词。

运行如下示例。

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

为何这么快呢?
这是因为我们使用了参数-XX:StringTableSize=200000,StringTable足够大,哈希冲突碰撞少。
去掉这个参数运行。变慢了,看到默认桶大小时60013.

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

可以看到慢了很多,因为StingTable底层是数组+链表,你需要在放一个新的串之前进行查找,有没有这个字符串。太小了就容易哈希冲突。查询慢。
总结:如果你的代码需要处理大量字符串,这些字符串会放到串池,可以适当增加StringTableSize的大小。
为何字符串对象要入池?什么情况要入池。
比如据说Twitter要存储用户地址,如果全部存,要30G内存。但这些地址大部分重复的。因此显式调用intern入池,就可以将需要内存减少到几百兆。
下面我们用一个demo来展示下。48万个词循环读取10次。存到list中防止被垃圾回收。代码逻辑中,使用System.in.read()来控制其运行进度。

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

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

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

内存占用显著下降。


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();
}
}
}直接内存为何快?
不使用直接内存,数据造成了不必要的复制

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

直接内存不受java程序管理,那它会不会造成内存溢出?
参考下面例子。

运行,溢出了。

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

运行下。

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

好像内存回收掉了。这是好消息,说明不会造成内存泄漏。
问题:那这跟GC有什么关系?不是说直接内存不归java程序管理?
实际上,这是因为jdk内部会调用一个unsafe类。而不是因为垃圾回收。一般不建议程序员自己使用unsafe类。
这里我们为了演示其工作流程,用它写个demo跑下。

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

回车。内存被回收了。

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



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

运行。

回车。

直接内存没有被回收。
怎么解决?
很简单,你自己手工释放下。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。