【JVM研习】目录大纲
书中内容来自于深入理解java虚拟机,作者周志明。会融合自己的知识和理解来记录下来,为了赚钱而奋斗!Day Day Up !!!(一)内存管理内存如何划分、内存溢出的原因内存分配和垃圾回收(二)虚拟机如何执行数据存储和访问类加载过程的5个阶段以及工作原理代码执行和执行的内存管理(三)程序编译的过程,以及代码如何优化编译器原理JIT编译优化技术(四)高效并发内...
书中内容来自于深入理解java虚拟机和Java虚拟机规范,以及网络文章。
文章目录
- 一、基本概念
- 二、[java代码的执行篇(程序编译的过程,以及代码如何优化)](https://xiaochengxinyizhan.blog.csdn.net/article/details/92385172)
- 三、内存管理篇
- 3.1. 内存如何划分、内存溢出的原因----[点击这里](https://blog.csdn.net/wolf_love666/article/details/86149936)
- 3.2. 内存分配和垃圾回收-----[点击这里](https://blog.csdn.net/wolf_love666/article/details/86165259)
- 3.3. 数据存储和访问([类文件结构](https://blog.csdn.net/wolf_love666/article/details/86598763)、[字节码](https://blog.csdn.net/wolf_love666/article/details/86615064)、[公有设计和私有实现](https://blog.csdn.net/wolf_love666/article/details/86615626))
- 四、线程资源同步和交互机制(高效并发)
- 五、优化篇
- 六、面试篇
- 七、[待补充资料](https://www.infoq.cn/profile/1278512)
JVM流程内容大纲图
一、基本概念
1.1、JVM
JVM是可运行Java代码的假想计算机,包括一套字节码指令集,一组寄存器(PC/程序计数器),栈(虚拟机栈/本地方法栈),堆,垃圾回收和方法区。JVM是运行在操作系统之上的,它与硬件没有直接的交互(所以可以跨平台,一处编译,处处运行)。
1.2、运行过程(详细内容参考Java代码的执行篇)
一共2个步骤:
以Hello.java为源文件,通过编译器,生成.class字节码文件
Jvm通过执行引擎,将字节码文件,转化为可执行的机器码指令。
每一种平台的解释器不同,但是实现的虚拟机是相同的,这也是Java跨平台的原因,当一个程序从开始运行,虚拟机开始实例化,多个程序启动就会存在多个虚拟机实例,程序退出或者关闭,则虚拟机实例消亡,多个虚拟机实例之间数据不能共享。JVM的概览(JVM8以后被元空间所替代,元空间可以通过MaxMetaSpace控制最大值,不设置默认依赖于实际内存大小。方法区的常量池移到堆,其余类加载信息维护到元空间)
1.3、内存模型涉及的名词及解析
- 程序计数器:这里记录了线程执行的字节码的行号,在分支、循环、跳转、异常、线程恢复等都依赖这个计数器。如果线程正在执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值为空(Undefined)。此内存区域是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
- java虚拟机栈:每个方法执行的时候都会创建一个栈帧(stack frame)用于存放 局部变量表、操作栈、动态链接、方法出口。每一个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机中入栈到出栈的过程。
其中虚拟机栈中的局部变量表部分是人们比较关心的部分。局部变量表存放了编译期可知的各种基本数据类型和returnAddress类型,需要注意的是其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,在方法运行期间不会改变局部变量表的大小。
- Java虚拟机栈有两种异常状况:StackOverflowError(超过栈深度)和OutOfMemoryError(动态扩展内存不足)异常。
- 本地方法栈:与虚拟机栈很类似,区别是一个shi是执行Java方法,一个是执行本地方法。有的虚拟机会把这2个栈合二为一。本地方法栈和虚拟机栈一样会出现StackOverflowError和OutOfMemoryError异常。
- Java堆:Java堆是Java虚拟机所管理的内存最大的一块,被所有线程共享的一块内存区域,在虚拟机启动的时候就创建了。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存(“几乎”是因为随着JIT编译器的发展和逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配在堆上也渐渐变得不那么绝对)。
- Java堆是垃圾收集器管理的主要区域,有时候也被称为“GC堆”。因为现在收集器基本都采用分代收集算法,所有Java堆还可以细分为:新生代和老年代,再细致一点有Eden空间、From Survivor空间、To Survivor空间等。堆是可以固定大小也是可以扩展的,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,会抛出OutOfMemoryError异常。
- 方法区:用于存储已被Java虚拟机加载的类信息、常量、静态变量、及时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它有一个别名叫Non-Heap(非堆),目的应该是与Java堆区分开来。
需要注意的是很多在HotSpot上开发的人员把方法区称为“永久代”,但是两者并不等价(HotSpot设计团队选择把GC分代手机扩展至方法区,或者说使用永久代来实现方法区而已)。在JDK1.8的HotSpot中,已经把原本放在永久代的字符串常量池移出,到堆中。方法区无法满足内存分配需求时会抛出OutOfMemoryError异常。- 运行时常量池:属于方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
- 相对于Class文件常量池,运行时常量池具备动态性,运行期间也可以将新的常量放入池中,平时利用较多的是String类的intern()方法。
- 直接内存:Deirect Memory并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁的使用,而已也可能导致OutOfMemoryError异常,所以需要注意。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
1.4、线程
这里所说的线程指程序执行过程中的一个线程实体。JVM 允许一个应用并发执行多个线程。
Hotspot JVM 中的 Java 线程与原生操作系统线程有直接的映射关系。当线程本地存储、缓冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个操作系统原生线程。 Java 线程结束,原生线程随之被回收。操作系统负责调度所有线程,并把它们分配到任何可
用的 CPU 上。当原生线程初始化完毕,就会调用 Java 线程的 run() 方法。当线程结束时,会释放原生线程和java线程的所有资源。
1.5、 JVM 运行时内存
Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。
新生代
是用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。新生代又分为 Eden 区、SurvivorFrom、SurvivorTo 三个区。
- Eden 区
Java 新对象的出生地 (如果新创建的对象占用内存很大,则直接分配到老年代)。当 >> Eden 区内存不够的时候就会触发MinorGC,对新生代区进行 一次垃圾回收。- SurvivorFrom 上一次 GC 的幸存者,作为这一次 GC 的被扫描者。
- SurvivorTo 保留了一次 MinorGC 过程中的幸存者。
- MinorGC 的过程 (复制->清空->互换) MinorGC 采用复制算法。
1、复制:将eden,survivorFrom 复制到 survivorTo,年龄+1,如果空间不够,则放到老年区
2、清空:将eden和survivorFrom中的对象清空
3、互换:survivorTo和survivorFrom互换,原来的survivorTo作为下一次GC的survivorFrom区。老年代
主要存放应用程序中生命周期长的内存对象。
老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC 前一般都先进>>行
了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无>>法找到足 够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾>>回收腾出空间。
MajorGC 采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没
有标记的对象。MajorGC 的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,>>为了减 少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也>>满了装不下的 时候,就会抛出 OOM(Out of Memory)异常。永久代
指内存的永久保存区域,主要存放 Class 和 MetaSpace(元数据)的信息,Class 在被加载>>的时候被放入永久区域,它和和存放实例的区域不同,GC 不会在主程序运行期对永久区>>域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛>>出OOM 异常。
JAVA8 与元数据
在Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间 的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用 本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java堆中,这样可以加载多少类的元数据就不再由 MaxPermSize 控制, 而由系统的实际可用空间来控制。
1.6、垃圾回收与算法
内存泄漏与自动内存回收(如何确定垃圾的策略)
- 引用计数法
我们已经用C ++的共享指针来演示的想法可以应用于所有对象。许多语言如Perl,Python或PHP都采用这种方法。这是最好的照片说明:
绿色的云表示它们指向的对象仍然被程序员使用。从技术上讲,这些可能是当前正在执行的方法中的局部变量或静态变量等事物。从编程语言到编程语言,它可能会有所不同,所以我们不会在此着重。
蓝色圆圈是内存中的对象,你可以看到它们的引用数量。最后,灰色圆圈是没有从任何范围引用的对象。灰色物体因此是垃圾并且可以由垃圾收集器清理。
这一切看起来非常好,不是吗?那么它确实存在,但是整个方法有一个巨大的缺点。很容易得到一个分离的对象循环,但它们都不在范围内,但由于循环引用,它们的引用计数不为零。这里有一个例子:
看到?实际上红色的对象是应用程序不使用的垃圾。但由于引用计数的局限性,仍然存在内存泄漏。- 可达性分析
- 垃圾收集根:
- 局部变量
- 全局变量
- 活动线程
- 静态字段
- JNI参考
- 其他(将在稍后讨论)
如果GC roots和一个对象之间没有可达路径,则称该对象是不可达的。需要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程,两次标记后仍然是可回收对象,则将面临回收。垃圾收集算法
- 引用计数法(如上会产生循环引用,现已废弃不再使用)
- 标记-清除(Mark-Sweep):
此算法执行分两阶段。第一阶段从引用根节点开始标记所有被引用的对象,第二阶段遍历整个堆,把未标记的对象清除。此算法需要暂停整个应用,同时,会产生内存碎片,以及后续可能发生大对象不能找到可用空间的问题。- 复制(Copying):
此算法把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域,把正在使用中的对象复制到另外一个区域中。次算法每次只处理正在使用中的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理,不会出现“碎片”问题。当然,此算法的缺点也是很明显的,就是需要两倍内存空间,以及存活对象增多的话,copying算法的效率会大大降低。- 标记-整理(Mark-Compact):
此算法结合了“标记-清除”和“复制”两个算法的优点。也是分两阶段,第一阶段从根节点开始标记所有被引用对象,第二阶段遍历整个堆,把清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放。此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。- 分代收集算法
分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。
- 新生代与复制算法
目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要
回收大部分对象,即要复制的操作比较少,但通常并不是按照 1:1 来划分新生代。一般将新生代
划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用
Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另
一块 Survivor 空间中。
- 老年代与标记复制算法
而老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。
- JAVA 虚拟机提到过的处于方法区的永生代(Permanet Generation),它用来存储 class 类,
常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。- 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目
前存放对象的那一块) ,少数情况会直接分配到老生代。- 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC ,进行GC 后,Eden
Space 和 From Space 区的存活对象会被挪到To Space ,然后将Eden Space 和 From
Space 进行清理。- 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。
- 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。
- 当对象在 Survivor 区躲过一次GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被移到老生代中。
- 分区收集算法
目前G1的算法思路,分区算法是将整个堆空间划分为连续的不同小区间,每个小区间独立使用,独立回收,这样做的好处可以控制一次回收多个小区间,根据目标停顿时间,每次合理的回收若干个小区间而不是整个堆,从而减少一次GC所产生的停顿。
1.7、Java中的四种引用类型
- 强引用
在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即 使该对象以后永远都不会被用到JVM 也不会回收。==因此强引用是造成Java 内存泄漏的主要原因之一。 ==- 软引用
软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。- 弱引用
弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。- 虚引用
虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚
引用的主要作用是跟踪对象被垃圾回收的状态。
1.8、垃圾收集器
Java堆内存被划分为新生代和年老代两部分,新生代主要使用复制和标记-清除垃圾回收算法,年老代主要使用标记-整理垃圾回收算法,因此java虚拟中针对新生代和年老代分别提供了多种不同的垃圾收集器,JDK1.6中Sun HotSpot虚拟机的垃圾收集器如下:
图中如果两个垃圾收集器直接有连线,则表明这两个垃圾收集器可以搭配使用。
- (1). Serial垃圾收集器:
Serial是最基本、历史最悠久的垃圾收集器,使用复制算法,曾经是JDK1.3.1之前新生代唯一的垃圾收集器。
Serial是一个单线程的收集器,它不仅仅只会使用一个CPU或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。
Serial垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个CPU环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此Serial垃圾收集器依然是java虚拟机运行在Client模式下默认的新生代垃圾收集器。- (2). ParNew垃圾收集器:
ParNew垃圾收集器其实是Serial收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和Serial收集器完全一样,ParNew垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。
ParNew收集器默认开启和CPU数目相同的线程数,可以通过-XX:ParallelGCThreads参数来限制垃圾收集器的线程数。
ParNew虽然是除了多线程外和Serial收集器几乎完全一样,但是ParNew垃圾收集器是很多java虚拟机运行在Server模式下新生代的默认垃圾收集器。- (3).Parallel Scavenge收集器:
Parallel Scavenge收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器,它重点关注的是程序达到一个可控制的吞吐量(Thoughput,CPU用于运行用户代码的时间/CPU总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),高吞吐量可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。
Parallel Scavenge收集器提供了两个参数用于精准控制吞吐量:
a.-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,是一个大于0的毫秒数。
b.-XX:GCTimeRation:直接设置吞吐量大小,是一个大于0小于100的整数,也就是程序运行时间占总时间的比率,默认值是99,即垃圾收集运行最大1%(1/(1+99))的垃圾收集时间。
Parallel Scavenge是吞吐量优先的垃圾收集器,它还提供一个参数:-XX:+UseAdaptiveSizePolicy,这是个开关参数,打开之后就不需要手动指定新生代大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、新生代晋升年老代对象年龄(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数以达到最大吞吐量,这种方式称为GC自适应调节策略,自适应调节策略也是ParallelScavenge收集器与ParNew收集器的一个重要区别。- (4).Serial Old收集器:
Serial Old是Serial垃圾收集器年老代版本,它同样是个单线程的收集器,使用标记-整理算法,这个收集器也主要是运行在Client默认的java虚拟机默认的年老代垃圾收集器。
在Server模式下,主要有两个用途:
a.在JDK1.5之前版本中与新生代的Parallel Scavenge收集器搭配使用。
b.作为年老代中使用CMS收集器的后备垃圾收集方案。
新生代Serial与年老代Serial Old搭配垃圾收集过程图:
新生代Parallel Scavenge收集器与ParNew收集器工作原理类似,都是多线程的收集器,都使用的是复制算法,在垃圾收集过程中都需要暂停所有的工作线程。
新生代Parallel Scavenge/ParNew与年老代Serial Old搭配垃圾收集过程图:
- (5).Parallel Old收集器:
Parallel Old收集器是Parallel Scavenge的年老代版本,使用多线程的标记-整理算法,在JDK1.6才开始提供。
在JDK1.6之前,新生代使用ParallelScavenge收集器只能搭配年老代的Serial Old收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代Parallel Scavenge和年老代Parallel Old收集器的搭配策略。
新生代Parallel Scavenge和年老代Parallel Old收集器搭配运行过程图:
- (6).CMS收集器:
Concurrent mark sweep(CMS)收集器是一种年老代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法。
最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验,CMS收集器是Sun HotSpot虚拟机中第一款真正意义上并发垃圾收集器,它第一次实现了让垃圾收集线程和用户线程同时工作。
CMS工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下4个阶段:
a.初始标记:只是标记一下GC Roots能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。
b.并发标记:进行GC Roots跟踪的过程,和用户线程一起工作,不需要暂停工作线程。
c.重新标记:为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程。
d.并发清除:清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。
由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS收集器的内存回收和用户线程是一起并发地执行。
CMS收集器工作过程:
CMS收集器有以下三个不足:
a. CMS收集器对CPU资源非常敏感,其默认启动的收集线程数=(CPU数量+3)/4,在用户程序本来CPU负荷已经比较高的情况下,如果还要分出CPU资源用来运行垃圾收集器线程,会使得CPU负载加重。
b. CMS无法处理浮动垃圾(Floating Garbage),可能会导致Concurrent ModeFailure失败而导致另一次Full GC。由于CMS收集器和用户线程并发运行,因此在收集过程中不断有新的垃圾产生,这些垃圾出现在标记过程之后,CMS无法在本次收集中处理掉它们,只好等待下一次GC时再将其清理掉,这些垃圾就称为浮动垃圾。
CMS垃圾收集器不能像其他垃圾收集器那样等待年老代机会完全被填满之后再进行收集,需要预留一部分空间供并发收集时的使用,可以通过参数-XX:CMSInitiatingOccupancyFraction来设置年老代空间达到多少的百分比时触发CMS进行垃圾收集,默认是68%。
如果在CMS运行期间,预留的内存无法满足程序需要,就会出现一次ConcurrentMode Failure失败,此时虚拟机将启动预备方案,使用Serial Old收集器重新进行年老代垃圾回收。
c. CMS收集器是基于标记-清除算法,因此不可避免会产生大量不连续的内存碎片,如果无法找到一块足够大的连续内存存放对象时,将会触发因此Full GC。CMS提供一个开关参数-XX:+UseCMSCompactAtFullCollection,用于指定在Full GC之后进行内存整理,内存整理会使得垃圾收集停顿时间变长,CMS提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,用于设置在执行多少次不压缩的Full GC之后,跟着再来一次内存整理。- (7).G1收集器:
Garbage first垃圾收集器是目前垃圾收集器理论发展的最前沿成果,相比与CMS收集器,G1收集器两个最突出的改进是:
a.基于标记-整理算法,不产生内存碎片。
b.可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。
G1收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。
区域划分和优先级区域回收机制,确保G1收集器可以在有限时间获得最高的垃圾收集效率。
二、java代码的执行篇(程序编译的过程,以及代码如何优化)
三、内存管理篇
3.1. 内存如何划分、内存溢出的原因----点击这里
3.2. 内存分配和垃圾回收-----点击这里
3.3. 数据存储和访问(类文件结构、字节码、公有设计和私有实现)
四、线程资源同步和交互机制(高效并发)
4.1. 内存模型是如何处理高并发问题----点击这里
4.2. 高效并发的锁优化技术----点击这里
五、优化篇
六、面试篇
JVM基础面试题及原理讲解
JVM面试题
JVM面试题
JVM面试专栏
七、待补充资料
更多推荐
所有评论(0)