
1. 为什么“堆存对象、栈存变量”这种说法一说就错刚入行那会儿我也是这么记的——面试官问“堆和栈分别存什么”我脱口而出“堆存对象栈存基本类型和引用”结果对方微微一笑“那String str new String(hello)这行代码里hello这个字面量存在哪儿str这个引用本身存在哪儿new出来的String对象又在哪儿三个东西三个位置你再捋一遍。”我当时就卡住了。不是记不住是根本没真正理解JVM内存区域的职责边界和数据生命周期。后来带新人时发现90%的人对“堆、栈、方法区”的认知都停留在教科书式的标签化记忆上堆对象仓库栈方法调用记录方法区类模板存放地。但真实世界里一个Java程序跑起来内存里流动的数据远比这三块区域的静态划分要复杂得多。比如你写ListString list new ArrayList(10);ArrayList实例在堆上没错list这个局部变量名在栈帧里也没错但那个容量为10的内部数组Object[] elementData它是在堆上分配的还是在栈上“跟着”ArrayList一起存再比如Lambda表达式编译后生成的类信息存在方法区可它捕获的外部变量值是复制进去了还是通过某种间接引用机制访问还有G1 GC里提到的“Remembered Set”它本身的数据结构存在哪儿这些都不是“堆/栈/方法区”三个词能直接回答的。真正决定某块数据落哪儿的从来不是“它是什么类型”而是它的作用域、生命周期、共享范围和访问方式。堆之所以存对象是因为对象需要跨方法、跨线程长期存在且可能被多个栈帧同时引用栈之所以存局部变量是因为方法调用有明确的进入退出顺序变量随方法调用而生、随方法返回而灭方法区之所以存类元数据是因为类定义是全局唯一的、只加载一次、供所有线程共享的“蓝图”。所以这篇文章不打算再重复一遍“堆是线程共享的运行时数据区用于存放对象实例……”这种百科式定义。我们要做的是把每一行Java代码像拆解电路板一样一层层剥开看它在JVM内存里到底触发了哪些区域的写入、读取、关联与释放。你会看到一个看似简单的int a 5;背后栈帧里不仅有a的值还有它的操作数栈快照、局部变量表索引、甚至可能触发常量池的符号解析而一个new Object()除了在堆上分配空间还会在方法区里检查类是否已加载在栈上记录构造器调用链在本地方法栈里处理可能的JNI调用。这才是JVM内存模型的真相——它不是一张静态分区图而是一套动态协作的内存协议。接下来我们就从最基础、也最容易被误解的“栈”开始亲手画出它的内部结构。2. 栈不只是“存变量”它是方法执行的实时快照很多人以为栈就是个“变量盒子”方法一进来变量往里一塞方法一出去盒子清空。这种理解漏掉了栈最核心的价值它是一份精确到字节码指令级别的、方法执行过程的完整状态快照。栈帧Stack Frame不是容器而是“现场”。2.1 栈帧的四大构件局部变量表、操作数栈、动态链接、方法出口当你调用一个方法JVM就在当前线程的Java虚拟机栈中压入一个新栈帧。这个栈帧绝非空壳它由四个关键部分构成每个部分都承担着不可替代的职责局部变量表Local Variable Table这是大家最熟悉的“存变量”的地方。但它存的远不止是int a 5;里的a。它按槽Slot编号每个槽32位宽能存一个int、float、reference引用或半个long/double64位类型占两个连续槽。重点来了this引用永远占据第0号槽。哪怕你写的是静态方法编译器也会在局部变量表里预留这个位置只是不使用。另外方法参数从第1号槽开始依次排列。所以public void test(String s, int i)这个方法s在槽1i在槽2假设i是int。而s.length()调用时length()方法的栈帧里this指向的就是s所引用的那个String对象——这个引用关系正是通过局部变量表的槽位传递的。操作数栈Operand Stack这是JVM执行引擎的“计算器”。字节码指令如iload_1把局部变量表第1槽的int值压入操作数栈、iadd弹出栈顶两个int相加结果再压入都是在操作数栈上完成的。它不像局部变量表有固定槽位而是个动态伸缩的栈。你可以把它想象成Excel里一个临时的、只保留当前计算中间结果的单元格。比如int c a b;编译后大概是iload_1a入栈→iload_2b入栈→iadd弹出a、b算和c入栈→istore_3c出栈存入局部变量表第3槽。整个加法运算全程在操作数栈上发生局部变量表只负责“进出库”。动态链接Dynamic Linking这是实现多态和反射的关键。每个栈帧都包含一个指向运行时常量池中该方法的引用。当执行invokespecial调用父类方法、invokestatic调用静态方法时JVM直接根据这个引用找到目标方法而执行invokevirtual调用虚方法时JVM会先查这个引用指向的类的方法表vtable再根据实际对象类型决定调用哪个具体实现。没有这个动态链接Animal a new Dog(); a.speak();就无法在运行时正确调用Dog的speak方法。方法出口Method Exit记录方法正常返回return或异常退出throw后应该跳转回哪里继续执行。它保存着调用者的下一条字节码指令地址。这也是为什么你能用IDE的“Drop Frame”功能把当前方法栈帧“弹掉”让程序回到上一层方法继续执行——JVM就是靠这个出口地址精准定位的。提示栈帧的大小在类加载的解析阶段就完全确定了由Code属性中的max_stack和max_locals决定不会在运行时改变。这也是为什么递归过深会直接抛StackOverflowError——不是栈“满了”而是新压入的栈帧所需空间超出了JVM为该线程预设的栈大小-Xss参数。2.2 一个真实案例String s abc;在栈上发生了什么我们来逐行拆解这行看似简单的代码public class StackDemo { public static void main(String[] args) { String s abc; System.out.println(s); } }编译后main方法的字节码如下简化版0: ldc #2 // 加载常量池#2即abc字符串 2: astore_1 // 将栈顶的引用存入局部变量表第1槽s 3: getstatic #3 // 获取System.out 6: aload_1 // 将局部变量表第1槽的引用s压入操作数栈 7: invokevirtual #4 // 调用println方法 10: return执行过程指令0ldc #2JVM去运行时常量池查找#2发现是CONSTANT_String_info它指向另一个CONSTANT_Utf8_infoabc。如果这是第一次使用JVM会检查字符串常量池StringTable位于堆中但逻辑上属于方法区管理范畴若无则创建并放入若有则直接返回其引用。这个引用被压入当前栈帧的操作数栈。指令2astore_1将操作数栈顶的引用abc的地址弹出存入当前栈帧的局部变量表第1槽。此时s这个“变量名”才正式有了归属。指令6aload_1又把局部变量表第1槽的引用重新压入操作数栈为后续println调用做准备。看到了吗同一个字符串引用在短短几条指令间就在操作数栈和局部变量表之间来回搬运了两次。而“abc”这个字符串对象本身存在于堆上的字符串常量池JDK 7之后它的类元数据String.class则在方法区。一个简单的赋值横跨了三个内存区域。2.3 实操验证用jclasslib和jstack亲眼看看栈帧光说不练假把式。你可以用两个工具亲手验证上面的结论jclasslib Bytecode Viewer打开编译好的.class文件切换到Code属性页就能看到上面那段字节码以及max_stack2, max_locals2args在槽0s在槽1。这印证了栈帧大小的静态性。jstack写一个无限递归的方法如public static void loop() { loop(); }然后用jstack pid抓取线程快照。你会看到类似这样的输出main #1 prio5 os_prio0 tid0x00007f8b8c00a000 nid0x2a0e waiting for monitor entry [0x00007f8b902f9000] java.lang.Thread.State: RUNNABLE at StackDemo.loop(StackDemo.java:5) at StackDemo.loop(StackDemo.java:5) at StackDemo.loop(StackDemo.java:5) ...这里[0x00007f8b902f9000]就是当前线程栈的起始地址后面一长串loop就是层层叠叠的栈帧。每个at代表一个栈帧的入口点。你可以清晰地看到栈帧是按调用顺序从下往上地址从高到低压入的。注意-Xss参数设置的是每个线程栈的总大小不是单个栈帧的大小。一个栈帧可能只占几百字节但几千个栈帧叠在一起就轻松耗尽-Xss设定的1MB或2MB空间。这也是为什么-Xss不能设得过大——线程一多内存就爆了。3. 堆对象的“户籍所在地”但户口本在方法区如果说栈是方法执行的“快照”那么堆就是所有对象的“户籍所在地”。但这里有个巨大的认知陷阱堆只管对象实例的“身体”不管它的“身份”。一个对象是谁、能干什么、有哪些字段和方法这些“身份信息”全在方法区里。3.1 对象在堆中的完整生命旅程从分配到消亡一个对象的诞生远比new SomeClass()这一行代码复杂。它要经历五个严格有序的阶段类加载检查Loading CheckJVM遇到new指令首先去方法区的常量池中检查是否有SomeClass的符号引用。如果没有说明类还没加载触发类加载过程加载、验证、准备、解析、初始化。内存分配Memory Allocation类已加载JVM在堆上为对象分配内存。分配方式有两种指针碰撞Bump the Pointer适用于内存规整的场景如Serial、ParNew收集器。堆内存被分为“已使用”和“未使用”两块用一个指针作为分界点。分配内存就是把指针向“未使用”方向挪动一段距离。快如闪电。空闲列表Free List适用于内存不规整的场景如CMS收集器。JVM维护一个列表记录堆中哪些内存块是可用的。分配时从列表中找到一块足够大的空间更新列表。稍慢但更灵活。关键细节分配内存不是原子操作多线程下两个线程可能同时修改指针或列表。JVM的解决方案是CASCompare and Swap 失败重试或者给每个线程在Eden区划出一块独立的“本地线程分配缓冲区TLAB”。绝大多数对象都在TLAB里分配几乎不涉及同步。初始化零值Zero Initialization内存分配完JVM会将分配到的内存空间除对象头外都初始化为零值0、0L、null、false等。这保证了Java对象的字段即使不显式赋值也能得到一个安全的默认值。注意这一步不执行任何Java代码int a;此时就是0不是你代码里写的int a 5;。设置对象头Setting Object HeaderJVM为对象设置对象头Object Header它包含两部分Mark Word存储哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分内容会随着对象状态变化而动态变化。Klass Pointer这是一个指向方法区中该对象所属类元数据的指针。这才是连接堆和方法区的关键纽带没有它JVM就不知道这个堆上的对象到底是个String还是个ArrayList。执行init方法ExecutinginitMethod最后JVM才会调用你写的构造函数即init方法执行你代码里写的int a 5;、this.name name;等逻辑。这才是真正的“初始化”。提示new指令只完成了前四步。第五步init是invokespecial指令触发的。所以如果你在构造器里启动了一个新线程并把this传给它那个新线程看到的是一个已经分配好内存、对象头已设置、但构造器代码尚未执行完毕的对象这就是著名的“构造器逃逸”问题可能导致其他线程看到未初始化完成的对象状态。3.2 堆的内部结构新生代、老年代、永久代/元空间现代JVMHotSpot的堆绝非一块大平板而是被精细地划分为几个子区域每个区域服务于不同的GC策略区域占比典型存储内容GC频率GC算法新生代Young Gen~25%新创建的对象、存活时间短的对象高每秒数次复制算法CopyingEden区~80% of Young绝大多数新对象在此分配同上同上Survivor S0/S1~10% each经过一次Minor GC后幸存的对象同上同上老年代Old Gen / Tenured~75%存活时间长、经历过多次Minor GC的对象低几分钟到几小时一次标记-清除Mark-Sweep或标记-整理Mark-Compact元空间Metaspace无固定上限受本地内存限制类的元数据类名、方法信息、常量池、字段描述等极低类卸载时无专门GC由本地内存管理这里必须澄清一个高频误区“永久代PermGen”在JDK 8中已被彻底移除由“元空间Metaspace”取代。永久代是堆的一部分受-XX:MaxPermSize限制而元空间使用的是本地内存Native Memory默认情况下只受物理内存限制可通过-XX:MaxMetaspaceSize手动限制。这意味着以前常见的java.lang.OutOfMemoryError: PermGen space错误在JDK 8中变成了java.lang.OutOfMemoryError: Metaspace。为什么这样改因为永久代的大小很难预估。Web应用如Tomcat热部署时每次部署都会加载大量新类旧类又未必能及时卸载导致永久代迅速撑爆。元空间用本地内存理论上可以无限扩展直到物理内存耗尽大大降低了这类OOM的风险。3.3 一个深度案例String.intern()如何在堆与方法区间“腾挪”String.intern()是检验你是否真懂堆与方法区关系的试金石。我们来看JDK 7的行为JDK 6及以前行为不同此处不讨论public class InternDemo { public static void main(String[] args) { String s1 new String(hello); // 在堆上创建新对象 String s2 hello; // 字符串字面量在字符串常量池堆中创建 String s3 s1.intern(); // 将s1的值hello加入字符串常量池并返回池中对象的引用 System.out.println(s1 s2); // false (堆对象 vs 常量池对象) System.out.println(s2 s3); // true (都是常量池中同一个对象) System.out.println(s1 s3); // false (同上) } }关键点解析new String(hello)在堆上创建了一个全新的String对象。它的value字段char[]也指向堆上的一块新内存。hello编译期确定的字面量JVM在类加载时就将其放入字符串常量池。JDK 7后这个池被移到了堆中准确说是堆的某个特定区域由JVM管理不再是方法区的一部分。s1.intern()JVM检查字符串常量池中是否已有值为hello的String对象。有则直接返回池中对象的引用没有则将s1的valuechar[]复制一份创建一个新的String对象放入池中再返回其引用。注意intern()操作本身并不改变s1的指向它只是返回一个新引用。所以s1 s3是false。而s2和s3都指向常量池中同一个对象所以s2 s3是true。这个例子完美展示了堆的“双重身份”它既是普通对象的家也是字符串常量池的家。而方法区元空间里只存着String.class这个类的元数据以及它所有静态方法如intern()的字节码。intern()方法的执行逻辑是由JVM用C实现的本地方法它负责协调堆常量池和方法区类定义之间的数据流转。4. 方法区类的“中央档案馆”但档案管理员在本地方法栈方法区Method Area是JVM规范中定义的概念在HotSpot虚拟机中它的具体实现就是元空间Metaspace。如果说堆是对象的“户籍所在地”那么方法区就是所有类的“中央档案馆”。它存储着每一个被JVM加载的类的全部元数据。4.1 方法区里到底存了什么一份完整的“类档案”清单一个类被加载进方法区其档案内容远比你想象的丰富。它主要包括以下几大类信息类的全限定名Fully Qualified Name如java/lang/String。这是类在JVM内的唯一身份标识。直接超类的全限定名如String的超类是Object记录为java/lang/Object。该类是类还是接口的标志一个布尔值。该类的访问修饰符public, final, abstract等以位掩码形式存储。常量池Constant Pool这是方法区里最庞大、也最核心的部分。它不是一个简单的字符串列表而是一个符号表Symbol Table里面存储着字面量Literal如字符串hello、整数123、浮点数3.14。符号引用Symbolic Reference如类名java/lang/Object、字段名value、方法名toString及其描述符()Ljava/lang/String;。这些引用在类加载的“解析”阶段才会被替换成直接引用Direct Reference即内存地址。字段信息Fields包括字段名、字段描述符如I表示intLjava/lang/String;表示String引用、字段的访问标志public, static, final等。方法信息Methods包括方法名、方法描述符参数和返回值类型、方法的访问标志public, static, final, synchronized等、方法的字节码Code属性、异常表Exceptions属性、行号表LineNumberTable属性用于调试、局部变量表LocalVariableTable属性用于调试。类变量Static Variables即static字段。它们的值被存储在方法区中而不是堆上。例如public static int COUNT 0;这个COUNT变量本身不是它的值0就存于方法区。指向类加载器ClassLoader和指向Class类的引用这是实现类隔离和反射的基础。每个类都有一个java.lang.Class对象它本身也是一个Java对象存于堆上但它内部有一个指向方法区中自己元数据的指针。提示Class对象是JVM在类加载过程中自动创建的它就像一个“活的类档案索引”。你通过String.class或obj.getClass()拿到的就是这个堆上的Class对象。它之所以能“反射”出类的所有信息正是因为它的内部指针牢牢地锚定在方法区的那份原始档案上。4.2 本地方法栈方法区的“后勤保障部”方法区里存的是“档案”但谁来“查阅”和“使用”这些档案答案是本地方法栈Native Method Stack。本地方法栈与Java虚拟机栈的作用非常相似区别在于Java虚拟机栈为JVM执行Java方法字节码服务而本地方法栈则为JVM调用的本地方法Native Method服务。这里的“本地方法”通常指用C/C等语言编写、通过JNIJava Native Interface注册到JVM中的方法。为什么需要它因为方法区里的类元数据如字节码、常量池是JVM内部格式Java代码无法直接操作。当你要做些底层操作比如Object.clone()需要直接复制内存块这必须由本地方法实现。System.currentTimeMillis()需要调用操作系统的gettimeofday()系统调用。String.getBytes()需要调用C库的字符编码转换函数。这些操作JVM都封装成了本地方法。当Java代码调用clone()时JVM会在本地方法栈中为clone()方法创建一个新的栈帧。执行该栈帧中的本地代码C函数。这个C函数会去方法区里查询Object类的元数据确认clone()方法的签名和权限。然后它会直接操作堆上的对象内存进行位拷贝。所以本地方法栈就是连接Java世界方法区、堆、Java栈与本地世界操作系统、C库的桥梁。它不存储业务数据但它决定了方法区里的“档案”能否被高效、安全地利用。4.3 实战排错java.lang.OutOfMemoryError: Metaspace的根因分析这是JDK 8最常见的OOM之一。很多同学一看到就慌了赶紧加-XX:MaxMetaspaceSize512m。但治标不治本。我们必须追根溯源。典型场景与排查链路场景Spring Boot应用反复热部署如DevTools现象每次修改代码IDE自动重启应用运行几次后应用启动失败报Metaspace OOM。根因每次重启Spring Boot会创建一个新的ClassLoader来加载所有类。旧的ClassLoader及其加载的所有类元数据在Metaspace中本应被GC回收。但如果某些静态集合如static MapClassLoader, Object cache持有了对旧ClassLoader的强引用就会导致这些类元数据无法被卸载Metaspace持续增长。验证用jstat -gc pid观察MUMetaspace Usage指标会发现它只增不减。用jmap -clstats pid查看类加载器统计会发现alive数量越来越多。场景使用CGLIB或Javassist动态生成大量代理类现象RPC框架如Dubbo或AOP框架如Spring AOP在运行时为每个被代理的类生成一个xxx$$EnhancerByCGLIB子类。如果代理的类非常多或代理逻辑有bug导致重复生成Metaspace就会被撑爆。根因动态生成的类其字节码被JVM当作普通类加载元数据同样进入Metaspace。而这些类通常没有对应的ClassLoader可以被轻易卸载。验证用jmap -histo:live pid | head -20会看到大量com.sun.proxy.$Proxy或net.sf.cglib.proxy.$EnhancerByCGLIB开头的类。场景自定义类加载器泄漏现象应用中实现了复杂的插件系统每个插件有自己的ClassLoader。插件卸载后其ClassLoader对象仍被持有。根因ClassLoader是类元数据的“主人”。只要ClassLoader对象还活着它加载的所有类的元数据就无法被Metaspace GC清理。验证用jmap -dump:formatb,fileheap.hprof pid导出堆转储用Eclipse MAT分析按ClassLoader分组看哪些ClassLoader实例数量异常多且其loadedClasses字段不为空。解决方案从来不是盲目加大-XX:MaxMetaspaceSize。正确的做法是用jstat -gc pid监控Metaspace使用趋势用jmap -clstats pid确认类加载器数量是否失控用jmap -histo或MAT分析定位是哪些类在疯狂增长最终修复代码中的类加载器泄漏或动态类生成逻辑。5. 四大区域的协同作战一个HTTP请求的完整内存足迹理论讲得再多不如看一个真实世界的完整案例。我们以一个典型的Spring MVC Web应用处理一个HTTP GET请求为例追踪整个请求生命周期中JVM四大内存区域堆、栈、方法区、本地方法栈是如何协同工作的。5.1 请求入口Tomcat线程池与Java栈的初次交锋当一个HTTP请求到达Tomcat它会被分配给线程池中的一个工作线程如http-nio-8080-exec-5。这个线程早已在JVM启动时就创建好了它拥有自己独立的Java虚拟机栈。Tomcat的AbstractEndpoint$Worker线程开始执行其栈帧里包含了run()方法的局部变量如SocketWrapperBase。接着调用Http11Processor.process()新的栈帧被压入局部变量表里存着SocketWrapper、InputBuffer、OutputBuffer等引用。再调用Adapter.service()栈帧再次压入此时request和response对象被创建并存入局部变量表。注意request和response对象本身是普通的Java对象它们被分配在堆上。但它们的“身份”——即它们是org.apache.catalina.connector.Request和org.apache.catalina.connector.Response类的实例——这个信息就存储在方法区的Request.class和Response.class元数据中。5.2 Spring MVC调度方法区的“指挥中心”与堆的“执行部队”Tomcat将request和response交给Spring的DispatcherServlet。此时真正的MVC调度开始了DispatcherServlet.doDispatch()被调用新的栈帧压入。它从HandlerMapping中查找能处理该URL的HandlerExecutionChain。HandlerExecutionChain是一个对象存于堆上。但它内部的handler字段是一个HandlerMethod对象而HandlerMethod里又包含一个Method对象反射API。Method对象本身在堆上但它所代表的“这个方法到底长什么样”其全部信息方法名、参数类型、注解RequestMapping的值都来自方法区中对应Controller类的元数据。当DispatcherServlet最终调用handlerMethod.invoke()时奇迹发生了JVM去方法区中查找该Method对象所指向的字节码。创建一个新的栈帧压入当前线程的Java虚拟机栈。在这个新栈帧的操作数栈上执行iload_0加载this、aload_1加载request等指令。this引用指向堆上的Controller实例request引用指向堆上的Request实例。整个过程方法区提供“剧本”元数据Java栈提供“舞台”执行上下文堆提供“演员”对象实例。5.3 数据库交互本地方法栈的“临门一脚”Controller方法里通常会有userRepository.findById(id)这样的调用。这背后是JDBC驱动在工作DriverManager.getConnection()最终会调用MySQLConnection的本地方法nativeConnect()。JVM为此在本地方法栈中创建栈帧。该本地方法会调用操作系统的socket()、connect()等系统调用建立TCP连接。连接建立后JDBC驱动会将SQL语句如SELECT * FROM user WHERE id ?序列化为MySQL协议的二进制包通过socket发送。MySQL服务器返回结果集后JDBC驱动的本地方法再将二进制数据解析为ResultSet对象这个对象被创建在堆上。这里本地方法栈是整个IO链条的“最后一公里”。它不存储业务数据但它让JVM能够突破Java语言的边界与操作系统和网络设备直接对话。5.4 响应返回四大区域的“谢幕”与“清场”请求处理完毕DispatcherServlet将ModelAndView对象交给ViewResolver最终渲染HTML写入response.getOutputStream()。渲染过程会产生大量临时字符串、StringBuilder对象它们在堆上分配、使用、然后被GC回收。response.getOutputStream()返回的ServletOutputStream对象其内部可能调用了FileDescriptor的本地方法这又会用到本地方法栈。当整个请求处理结束http-nio-8080-exec-5线程的Java虚拟机栈上所有为本次请求压入的栈帧会按照后进先出LIFO的顺序逐个弹出、销毁。局部变量表、操作数栈占用的内存被立即释放。堆上为本次请求创建的User、Order、StringBuilder等对象如果不再被任何栈帧或静态变量引用就会在下一次GC时被回收。方法区和本地方法栈由于它们存储的是全局、长期的元数据和本地代码基本不受单次请求影响保持稳定。这就是一个HTTP请求在JVM内存世界里的完整“一生”。它不是孤立地待在某一个区域而是像一支训练有素的军队在堆兵员、栈指挥链、方法区作战地图、本地方法栈特种装备四大区域的精密协同下完成了一次完美的任务。6. 面试高频题深度拆解为什么String str new String(abc)创建了几个对象这个问题几乎是JVM内存区域考察的“必答题”。但绝大多数面试者只能答出“2个”却说不清“为什么是2个”、“第2个在哪”、“常量池在哪”。我们来一次彻底的、带证据的拆解。6.1 JDK 7的权威答案最多2个对象但“2”的含义完全不同结论先行在JDK 7及以后String str new String(abc);这行代码最多创建2个对象。但这2个对象一个在堆上一个在字符串常量池也在堆上不存在“方法区里有一个”的情况。让我们用javap -v反编译来验证public class StringTest { public static void main(String[] args) { String str new String(abc); } }编译后main方法的字节码和常量池如下关键部分Constant pool: #2 String #18 // abc #18 Utf8 abc Code: 0: new #2 // class java/lang/String 3: dup 4: ldc #2 // String abc 6: invokespecial #3 // Method java/lang/String.init:(Ljava/lang/String;)V 9: astore_1执行步骤详解指令0new #2JVM去常量池#2查找发现它是一个String类型的符号引用