说在前面前两篇写了 JVM 五大区域的全貌又单独拆了堆和栈的区别。这一篇来补剩下的两个区域——方法区和程序计数器。为什么把它们放一起因为我学的时候发现这两个东西一个特别抽象方法区一个特别小程序计数器但都挺重要。尤其是方法区里的符号引用转直接引用这个概念卡了我好一会儿。先串一串前两篇说了什么到目前为止JVM 五大区域我们已经聊了三个Java 堆——最大的一块放对象实例和数组GC 最忙的地方Java 虚拟机栈——每个方法调用时压入栈帧方法结束弹出本地方法栈——和虚拟机栈差不多但给 native 方法用HotSpot 里合二为一了还剩下两个方法区JDK 8 后叫元空间程序计数器这篇就是补它俩的。方法区一个很容易被弄混的概念先说一下为什么它会让我困惑。第一篇文章里说过方法区是堆的逻辑组成部分但有个别名叫非堆。这句话当时我就没看懂——什么叫逻辑上属于堆实际上又不是堆后来我才理解方法区是 JVM 规范上的一个概念元空间是它的具体实现。规范上说有这么一块区域JDK 7 的时候它实现在永久代PermGen在堆里JDK 8 之后它实现在元空间Metaspace用本地内存不在堆里。所以严格来说元空间已经不是堆的一部分了。但很多资料还在说方法区属于堆那是从规范概念的角度说的不是从实际内存的角度。方法区里到底放了什么方法区里存的内容还挺杂的我列了一下类信息——这个类的结构、访问修饰符public/private 这些、继承了哪个父类、实现了哪些接口方法字节码——每个方法编译后的字节码指令静态变量——类里的 static 字段运行时常量池——这个下面单独说JIT 编译后的代码缓存——热点方法被即时编译器编译成本地机器码后放在 CodeCache 里当时我看到这个列表脑子里冒出一个问题这些东西不都是.class 文件里的内容吗为什么加载到内存里要单独放一个区后来我大概明白了class 文件是硬盘上的静态文件方法区是内存里的运行时形态。JVM 要把 class 文件里的信息读出来、解析好、放到内存里才能在程序跑起来的时候随时取用。你总不能每次调用一个方法都去硬盘上读一次字节码吧符号引用 vs 直接引用——这个卡了我很久说实话这是我学方法区的时候最绕的一个概念。什么是符号引用符号引用说白了就是用一个名字来描述目标。比如你在代码里写UserusernewUser();编译之后.class 文件里不会记录User类在内存里的具体地址因为这时候还没加载谁知道它在哪儿而是记了一个字符串“com/example/User”。这个字符串就是符号引用——它只是一个符号一个名字不依赖具体的内存布局。什么是直接引用直接引用就好理解了——就是一个指针、偏移量或者句柄能直接定位到内存里的目标。就好比你去一个陌生的城市找人。符号引用就是你知道那个人的名字叫张三直接引用就是你拿到了他的具体地址“XX 市 XX 路 XX 号 301 室”。什么时候转换在类加载的解析阶段JVM 会把符号引用替换成直接引用。这样真正运行的时候就不需要再去根据名字找了直接靠地址跳过去就行。方法是怎么执行起来的学到这里我串了一下方法的执行过程发现其实是这么几步第一步解析方法调用JVM 看到代码里调了一个方法比如user.getName()。它先拿着方法名和方法签名这些符号引用去查找到这个方法在哪儿。如果之前已经解析过了这一步就跳过。第二步创建栈帧在当前线程的栈里给这个方法分配一个栈帧。栈帧里装着局部变量表、操作数栈、动态链接、方法出口这些东西——第二篇说过。第三步执行字节码JVM 开始一条一条地执行这个方法里的字节码指令。可能涉及到变量的读写、运算、跳转、创建对象、调用别的方法等等。第四步返回处理方法执行完之后可能会返回一个结果。然后清理当前栈帧弹出恢复调用者的执行环境。这一套流程我画了一个极简版本方便自己记看到方法调用 → 定位方法地址 → 分配栈帧 → 跑字节码 → 返回清理运行时常量池运行时常量池是方法区的一部分。每个 .class 文件里面都有一个常量池Constant Pool记录着类里的字面量和符号引用。类加载之后这个常量池就被搬到方法区里变成运行时常量池。里面主要存两类东西字面量——比如字符串hello、数值100、final 常量符号引用——类名、方法名、字段名的符号描述动态性运行时常量池比 class 文件的常量池多了一个特点动态性。意思就是不光是编译期放进去的东西能在里面程序跑起来之后也能往里放新的常量。最典型的例子就是String.intern()方法——你可以在运行时把一个字符串注册到常量池里。内存不够的时候也会抛OutOfMemoryError。程序计数器——最小但很关键程序计数器在第一篇里其实说过了但这篇既然专门讲它我再串一遍。它是干什么的它的作用很简单记录当前线程执行到哪一行字节码了。你可以想象你在看一本很厚的书CPU 就是你的阅读时间。但 CPU 不是只读一本书——它同时读好几本书每本书读几页就换一本。这时候你需要给每本书夹一个书签翻回来的时候才知道刚才看到哪一页了。程序计数器就是这个书签。三个有意思的点第一执行 native 方法的时候它没值。因为 native 方法走的是本地指令不是 JVM 的字节码。你让一个 Java 的书签去记一本 C 语言的书读到哪了它记不了。第二它是五大区域里唯一不会 OOM 的。规范里就没给它留 OutOfMemoryError 这个异常。因为它太小了存储的内容也固定就是一个地址没有扩展空间的概念不存在内存不够的情况。第三它是线程私有的。为什么之前也说过——每个线程各读各的代码各走各的执行路径。如果共用一个计数器线程一切换回来都不知道谁是自己的书签了。最后串一下五大区域里哪些是线程私有的、哪些是共享的学到这里JVM 五大区域算是全过了一遍。我把它们的可见性再总结一次线程私有的每个线程各有一份程序计数器Java 虚拟机栈本地方法栈线程共享的所有线程共用一份Java 堆方法区元空间这个分类是 JVM 内存管理的一个基础框架。记牢这个后面学 GC、学并发、学调优都有一个地图可以参照。一个让我觉得原来如此的片段学这篇的过程中我最大的啊哈时刻是关于符号引用转直接引用的。以前我看 class 文件反编译出来的东西看到那些#1 Class#2 Methodref之类的标记只觉得是 class 文件的内部格式。后来才知道这些符号引用不仅仅是为了文件格式——它们的存在是因为 JVM 在编译期还不知道运行时内存布局只能先用符号占位等到类加载的时候再对号入座。它不是顺便这么做而是只能这样做。这个理解让我对这个机制不再觉得是背诵点了。