深入理解JVM4:内存结构篇(方法区)

此篇博客主要以笔记的形式,记录笔者在B站《深入理解JVM》课程中学到的知识点。课程地址:《黑马程序员JVM完整教程,全网超高评价,全程干货不拖沓》

上图为JVM内存结构概况图,本篇博客我们将来探究JVM中的方法区(图片来自课程截图)

1.方法区的定义

这里笔者引用oracle官网jdk1.8对方法区的定义(下面是来自百度翻译的译文,需要查看原文的读者可以进入此链接:The Structure of the Java Virtual Machine)。

Java虚拟机有一个在所有Java虚拟机线程之间共享的方法区域。方法区域类似于常规语言编译代码的存储区域,或类似于操作系统进程中的“文本”段。它存储每个类的信息,例如 运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化以及接口初始化中使用的特殊方法

方法区域是在虚拟机启动时创建的。尽管 方法区域在逻辑上是堆的一部分,但简单的实现可能选择不进行垃圾收集或压缩 。本规范不强制要求方法区域的位置或用于管理已编译代码的策略。方法区域可以是固定大小,也可以根据计算要求进行扩展,如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的。

Java虚拟机实现可为程序员或用户提供对方法区域初始大小的控制,以及在大小不同的方法区域的情况下,对最大和最小方法区域大小的控制。

以下异常情况与方法区域相关:

如果方法区域中的内存无法满足分配请求,Java虚拟机将抛出OutOfMemoryError异常。


2.Java6和8方法区的区别

  • Java6中的方法区称为 永久代,在逻辑上属于堆的一部分。
  • Java8中的方法区称为 元空间,并且不在虚拟机中,而是在本地内存中


上面两张图片分别表示Java6与Java8中,JVM方法区的结构示意图(两张图片均来自课程截图)。


3.运行时常量池

在探究运行时常量池之前,我们先来了解一下常量池。

常量池就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息等。

以下面的hello world为例,在命令行中执行javap -v Demo2.class 命令,对Demo2.class文件进行反编译(下面是反编译后的内容)
在这里插入图片描述
反编译后出来的信息大概分为三种:类的基本信息常量池类方法定义(如:构造方法、main方法等),其中Constant poll模块就是常量池。

接下来我们来按照反编译出来的信息对虚拟机执行main方法的步骤走一遍

// 反编译后常量池的信息
Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // hello world
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // com/bosen/www/Demo3
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/bosen/www/Demo3;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               Demo3.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               hello world
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               com/bosen/www/Demo3
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V
// 反编译后main方法的信息
{
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}
  1. 进入到main方法后,执行的第一条代码命令是getstatic #2,此时需要到常量池查找#2
  2. 来到常量池,发现#2 = Fieldref #21.#22,则继续往下走寻找#21#22,可以陆续找到java/lang/System对象和java/io/PrintStream对象,即对应的是代码中的 Systeam.out
  3. 执行完上一条指令后执行ldc #3,再次到常量池中查找#3#3 = String #23 --> #23 = Utf8 hello world,此时找到了需要打印的字符串 hello world
  4. 得到字符串后紧接着是打印的指令invokevirtual #4 --> #4 = Methodref #24.#25最终调用方法println实现字符串的打印功能,即 System.out.println("hello world");

懂得了常量池基本的工作原理后,理解运行时常量池将会变得容易很多。常量池是.class文件中的,当该类被加载时,它的常量池信息就会放入到运行时常量池中,并把里面的符号地址变为真实的地址~!

为什么需要将常量池信息放入运行时常量池?

  • 因为在实际业务逻辑中,并不只是打印一段“hello world”这么简单的需求,往往需要多个类共同工作。但常量池记录的只是一个类的常量信息,只有当需要合作的类常量信息都放在一起时,才可以保证类和类之间的合作~!

4.串池StringTable

特性

  1. 常量池中的字符串仅是符号,第一次用到时才变为对象
  2. 利用串池的机制,来避免重复创建字符串对象
  3. 字符串变量拼接的原理是StringBuilder
  4. 字符串常量拼接原理是编译器优化
  5. 可以使用intern方法,主动将串池中还没有的字符串对象放入串池

接下来我们将用下面几个具体的代码示例来说明上述的特性

String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();

String x1 = new String("c") + new String ("d");
String x2 = "cd";
x1.intern();

String x3 = new String("e") + new String ("f");
x3.intern();
String x4 = "ef";

System.out.println("示例一:" + (s3 == s4)); // false
System.out.println("示例二:" + (s3 == s5)); // true
System.out.println("示例三:" + (s3 == s6)); // true
System.out.println("示例四:" + (x1 == x2)); // false
System.out.println("示例五:" + (x3 == x4)); // true

示例一

String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;

System.out.println("示例一:" + (s3 == s4)); // false

执行上述代码后,堆中内存的变化如下:

因为s1s2s3中的字符串是直接用双引号定义的,因此这三个对象都会直接存在串池中。但是s4是通过s1+s2的方式对两个String类型的对象进行拼接的,JVM在会使用StringBuilder对象来完成拼接操作并重新实例化一个已经拼接好的字符串对象,而这种通过实例化即new出来的对象并不会存入串池中。

因此,s3s4的值虽然都是ab,但是两个指向的并不是同一内存地址,s3 != s4


示例二

String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s5 = "a" + "b";

System.out.println("示例二:" + (s3 == s5)); // true

执行上述代码后,堆中内存的变化如下:

会发现,在串池中只会存在一个ab字符串,并不会出现两个。这是因为在执行String s3 = "ab"时,虚拟机会先到串池中查找是否存在该字符串,如果没有则加入串池,有则返回该字符串在串池的地址。而在执行String s5 = "a" + "b"时,虚拟机会对其进行拼接后再与串池中的字符串进行对比,而拼接后的结果为ab,此时在串池中已经存在了字符串ab,所以返回该字符串在串池中的地址信息。

因此,s3s5指向的都是同一内存地址,s3 == s4


示例三

String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s6 = s4.intern();

System.out.println("示例三:" + (s3 == s6)); // true

执行上述代码后,堆中内存的变化如下:

通过示例一,我们知道,s4中的字符串是不存储在串池中的,但我们可以通过调用intern方法将在堆中的存在但串池中不存在的字符串对象存储到串池中;如果串池中已经存在该字符串对象,则不会将该对象存储到串池中。(需要注意的是:jdk1.8中是将堆中的字符串移动到串池,而1.6则是复制堆中的对象到串池)

综上所述,s4中的字符串对象会执行intern方法,但由于串池中已经存在字符串ab,所以s6对应的内存地址应该是原本存储在串池中ab的内存地址,因此s6的内存地址s3是一致的,即s3 == s6


示例四

String x1 = new String("c") + new String ("d");
String x2 = "cd";
x1.intern();

System.out.println("示例四:" + (x1 == x2)); // false

执行上述代码后,堆中内存的变化如下:

赋值x1时,字符串c、d都是通过new创建的,并且通过+号拼接,因此这段代码将会在堆中存入三个字符串对象(c, d, cd),而x2则是通过双引号定义字符串将直接存入到串池中的。接着调用x1intern方法,由于此时串池中已经存在字符串cd,所以x1对应的字符串对象,即堆中的cd并不会存储到串池中。

因此,x1与x2指向的内存地址是不一致的,即x1 != x2


示例五

String x3 = new String("e") + new String ("f");
x3.intern();
String x4 = "ef";

System.out.println("示例五:" + (x3 == x4)); // true

执行上述代码后,堆中内存的变化如下:

示例五与示例四相似。首先在堆中会产生三个字符串对象(e、f、ef),由于x3调用了intern方法,因此ef将会被存储到串池中,并且x3指向的内存地址也将会在串池中的(再次强调:jdk1.8中是将堆中的字符串移动到串池,而1.6则是复制堆中的对象到串池),所以,执行String x4 = "ef"时,串池将不会再次创建新的字符串对象,因为此时串池中已经存在ef

因此,x3与x4指向的内存地址是一致的,即x3 == x4


5.一些小疑惑

在学习过程中,笔者对于方法区的一些问题产生了疑惑,特在此记录一下:

常量池和串池的区别: 常量池中存储的只是一些常量符号,还没真正生成对应的字符串对象,需要运行加载操作时候,才会真正生成对象。而串池存储的是实在的字符串对象。

为什么1.6与1.8的串池在虚拟机中的位置发生了改变: 在1.6中串池是设置在方法区中的,当到了1.8却设置在了堆中。其主要的原因是因为串池中的对象使用频率高,因而需要进行gc的频率也高。所以设计者从程序运行效率上考虑,将串池设置在了堆中。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
深入理解JVM第四版》是一本关于Java虚拟机(JVM)原理和实现的经典著作。它由周志明所著,共计300页。这本书的主要目的是教会读者如何深入理解并掌握JVM的工作原理和内部机制。 本书首先介绍了JVM的基本概念和结构。它详细解释了JVM如何加载、验证、解析和初始化Java类。此外,书中还涉及了运行时数据域的结构和功能,包括堆、栈、方法等。 接下来,本书讨论了JVM的垃圾回收机制。它介绍了不同类型的垃圾回收算法和相关的性能调优技术。读者可以通过阅读这一部分,了解如何优化程序的内存使用和垃圾回收效率。 此外,本书还涵盖了JVM的即时编译器和优化技术。它详细介绍了JIT编译器的工作原理,并解释了常用的优化技术,如内联、逃逸分析和锁消除等。这对于那些希望通过编写高效的Java代码来提高程序性能的开发人员来说非常有用。 最后,本书还提供了一些高级主题,如类加载器、字节码增强和调试技术。通过阅读这些章节,读者可以加深对JVM内部机制的理解,并学习如何调优和调试JVM相关的问题。 总体而言,《深入理解JVM第四版》是一本全面而深入的JVM学习资料。它适合那些希望更深入了解JVM内部工作原理的Java开发人员。无论是学生、工程师还是研究人员,都可以从这本书中获得宝贵的知识和技巧。读者可以通过仔细阅读和实践书中的示例代码,提升自己的Java编程能力和理解JVM的水平。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

云丶言

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值