深入解析JVM之内存结构及字符串常量池(推荐)

前言

Java作为一种平台无关性的语言,其主要依靠于Java虚拟机——JVM,我们写好的代码会被编译成class文件,再由JVM进行加载、解析、执行,而JVM有统一的规范,所以我们不需要像C++那样需要程序员自己关注平台,大大方便了我们的开发。另外,能够运行在JVM上的并只有Java,只要能够编译生成合乎规范的class文件的语言都是可以跑在JVM上的。而作为一名Java开发,JVM是我们必须要学习了解的基础,也是通向高级及更高层次的必修课;但JVM的体系非常庞大,且术语非常多,所以初学者对此非常的头疼。本系列文章就是笔者自己对于JVM的核心知识(内存结构、类加载、对象创建、垃圾回收等)以及性能调优的学习总结,另外未特别指出本系列文章都是基于HotSpot虚拟机进行讲解。

正文

JVM包含了非常多的知识,比较核心的有内存结构、类加载、类文件结构、垃圾回收、执行 引擎、性能调优、监控等等这些知识,但所有的功能都是围绕着内存结构展开的,因为我们编译后的代码信息在运行过程中都是存在于JVM自身的内存区域中的,并且这块区域相当的智能,不需要C++那样需要我们自己手动释放内存,它实现了自动垃圾回收机制,这也是Java广受喜爱的原因之一。因此,学习JVM我们首先就得了解其内存结构,熟悉包含的东西,才能更好的学习后面的知识。

内存结构

如上图所示,JVM运行时数据区(即内存结构)整体上划分为线程私有和线程共享区域,线程私有的区域生命周期与线程相同,线程共享区域则存在于整个运行期间 。而按照JVM规范细分则分为程序计数器、虚拟机栈、本地方法栈、方法区和堆五大区域(直接内存不属于JVM)。注意这只是规范定义需要存在的区域,具体的实现则不在规范的定义中。

1. 程序计数器

如其名,这个部件就是用来记录程序执行的地址的,循环、跳转、异常等等需要依靠它。为什么它是线程私有的呢?以单核CPU为例,多线程在执行时是轮流执行的,那么当线程暂停后恢复就需要程序计数器恢复到暂停前的执行位置继续执行,所以必然是每个线程对应一个。由于它只需记录一个执行地址,所以它是五大区域中唯一一个不会出现OOM(内存溢出)的区域。另外它是控制我们JAVA代码的执行的,在调用native方法时该计数器就没有作用了,而是会由操作系统的计数器控制。

2. 虚拟机栈

虚拟机栈是方法执行的内存区域,每调用一个方法都会生成一个栈帧压入栈中,当方法执行完成才会弹出栈。栈帧中又包含了局部变量表、操作数栈、动态链接、方法出口。其中局部变量表就是用来存储局部变量的(基本类型值和对象的引用),每一个位置32位,而像long/double这样的变量则需要占用两个槽位;操作数栈则类似于缓存,用于存储执行引擎在计算时需要用到的局部变量;动态链接这里暂时不讲,后面的章节会详细分析;方法出口则包含异常出口和正常出口以及返回地址。下面来看三个方法示例分别展示栈和栈帧的运行原理。

入栈出栈过程

public class ClassDemo1 {
 public static void main(String[] args) {
  new ClassDemo1().a();
 }
 static void a() { new ClassDemo1().b(); }
 static void b() { new ClassDemo1().c(); }
 static void c() {}

}

如上所示的方法调用入栈出栈的过程如下:

栈帧执行原理

public class ClassDemo2 {

 public int work() {
  int x = 3;
  int y = 5;
  int z = (x + y) * 10;
  return z;
 }

 public static void main(String[] args) {
  new ClassDemo2().work();
 }

}

上面只是一简单的计算程序,通过javap -c ClassDemo2.class命令反编译后看看生成的字节码:

public class cn.dark.ClassDemo {
 public cn.dark.ClassDemo();
 Code:
  0: aload_0
  1: invokespecial #1     // Method java/lang/Object."<init>":()V
  4: return

 public int work();
 Code:
  0: iconst_3
  1: istore_1
  2: iconst_5
  3: istore_2
  4: iload_1
  5: iload_2
  6: iadd
  7: bipush  10
  9: imul
  10: istore_3
  11: iload_3
  12: ireturn

 public static void main(java.lang.String[]);
 Code:
  0: new   #2     // class cn/dark/ClassDemo
  3: dup
  4: invokespecial #3     // Method "<init>":()V
  7: invokevirtual #4     // Method work:()I
  10: pop
  11: return
}

主要看到work方法中,挨个来解释(字节码指令释义可以参照这篇文章):执行引擎首先通过iconst_3将常量3存入到操作数栈中,然后通过istore_1将该值从操作数栈中取出并存入到局部变量表的1号位(注意局部变量表示从0号开始的,但0号位默认存储了this变量);接着常量5执行同样的操作,完成后局部变量表中就存了3个变量(this、3、5);之后通过iload指令将局表变量表对应位置的变量加载到操作数栈中,因为这里有括号,所以先加载两个变量到操作数栈并执行括号中的加法,即调用iadd加法指令(所有二元算数指令会从操作数栈中取出顶部的两个变量进行计算,计算结果自动加入到栈中);接着又将常量10压入到栈中,继续调用imul乘法指令,完成后需要通过istore命令再将结果存入到局部变量表中,最后通过ireturn返回(不管我们方法是否定义了返回值都会调用该指令,只是当我们定义了返回值时,首先会通过iload指令加载局部变量表的值并返回给调用者)。以上就是栈帧的运行原理。

该区域同样是线程私有,每个线程对应会生成一个栈,并且每个栈默认大小是1M,但也不是绝对,根据操作系统不同会有所不

一样,另外可以用-Xss控制大小,官方文档对该该参数解释如下:

既然可以控制大小,那么这块区域自然就会存在内存不足的情况,对于栈当内存不足时会出现下面两种异常:

  • 栈溢出(StackOverflowError)
  • 内存溢出(OutOfMemoryError)

为什么会有两种异常呢?在周志明的《深入理解Java虚拟机》一书中讲到,在单线程环境下只会出现StackOverflowError异常,即栈帧填满了栈或局部变量表过大;而OutOfMemoryError只有当多线程情况下,无节制的创建多个栈才会出现,因为操作系统对于每个进程是有内存限制的,即超出了进程可用的内存,无法创建新的栈。

  • 栈帧共享机制

通过上文我们知道同一个线程内每个方法的调用会对应生成相应的栈帧,而栈帧又包含了局部变量表和操作数栈等内容,那么当方法间传递参数时是否可以优化,使得它们共享一部分内存空间呢?答案是肯定的,像下面这段代码:

public int work(int x) throws Exception{
  int z =(x+5)*10;// 参数会按照顺序放到局部变量表
  Thread.sleep(Integer.MAX_VALUE);
  return z;
 }
 public static void main(String[] args)throws Exception {
  JVMStack jvmStack = new JVMStack();
  jvmStack.work(10);//10 放入操作数栈
 }

在main方法中首先会把10放入操作数栈然后传递给work方法,作为参数,会按照顺序放入到局部变量表中,所以x会放到局部变量表的1号位(0号位是this),而此时通过HSDB工具查看这时的栈调用信息会发现如下情况:

如上图所示,中间一小块用红框圈起来的就是两个栈帧共享的内存区域,即work的局部变量表和main的操作数栈的一部分。

3. 本地方法栈

和虚拟机栈是一样的,只不过该区域是用来执行本地本地方法的,有些虚拟机甚至直接将其和虚拟机栈合二为一,如HotSpot。(通过上面的图也可以看到,最上面显示了Thread.sleep()的栈帧信息,并标记了native)

4. 方法区

该区域是线程共享的区域,用来存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。该区域在JDK1.7以前是以永久代方式实现的,存在于堆中,可以通过-XX:PermSize(初始值)、-XX:MaxPermSize(最大值)参数设置大小;而1.8以后以元空间方式实现,使用的是直接内存(但运行时常量池和静态变量仍放在堆中),可以通过-XX:MetaspaceSize(初始值)、-XX:MaxMetaspaceSize(最大值)控制大小,如果不设置则只受限于本地内存大小。为什么会这么改变呢?因为方法区和堆都会进行垃圾回收,但是方法区中的信息相对比较静态,回收难以达到成效,同时需要占用的空间大小更多的取决于我们class的大小和数量,即对该区域难以设置一个合理的大小,所以将其直接放到本地内存中是非常有用且合理的。

在方法区中还存在常量池(1.7后放入堆中),而常量池也分了几种,常常让初学者比较困惑,比如静态常量池、运行时常量池、字符串常量池。静态常量池就是指存在于我们的class文件中的常量池,通过javap -v ClassDemo.class反编译上面的代码可以看到该常量池:

Constant pool:
 #1 = Methodref   #5.#26   // java/lang/Object."<init>":()V
 #2 = Class    #27   // cn/dark/ClassDemo
 #3 = Methodref   #2.#26   // cn/dark/ClassDemo."<init>":()V
 #4 = Methodref   #2.#28   // cn/dark/ClassDemo.work:()I
 #5 = Class    #29   // java/lang/Object
 #6 = Utf8    <init>
 #7 = Utf8    ()V
 #8 = Utf8    Code
 #9 = Utf8    LineNumberTable
 #10 = Utf8    LocalVariableTable
 #11 = Utf8    this
 #12 = Utf8    Lcn/dark/ClassDemo;
 #13 = Utf8    work
 #14 = Utf8    ()I
 #15 = Utf8    x
 #16 = Utf8    I
 #17 = Utf8    y
 #18 = Utf8    z
 #19 = Utf8    main
 #20 = Utf8    ([Ljava/lang/String;)V
 #21 = Utf8    args
 #22 = Utf8    [Ljava/lang/String;
 #23 = Utf8    MethodParameters
 #24 = Utf8    SourceFile
 #25 = Utf8    ClassDemo.java
 #26 = NameAndType  #6:#7   // "<init>":()V
 #27 = Utf8    cn/dark/ClassDemo
 #28 = NameAndType  #13:#14  // work:()I
 #29 = Utf8    java/lang/Object

静态常量池中就是存储了类和方法的信息、符号引用以及字面量等东西,当类加载到内存中后,JVM就会将这些内容存放到运行时常量池中,同时会将符号引用(可以理解为对象方法的定位描述符)会被解析为直接引用(即对象的内存地址)存入到运行时常量池中(因为在类加载之前并不知道符号引用所对应的对象内存地址是多少,需要用符号替代)。而字符串常量池网上争议比较多,我个人理解它也是运行时常量池的一部分,专门用于存储字符串常量,这里先简单提一下,稍后会详细分析字符串常量池。

5. 堆

这个区域是垃圾回收的重点区域,对象都存在于堆中(但随着JIT编译器的发展和逃逸分析技术的成熟,对象也不一定都是存在于堆中),可以通过-Xms(最小值)、-Xmx(最大值)、-Xmn(新生代大小)、-XX:NewSize(新生代最小值)、-XX:MaxNewSize(新生代最大值)这些参数进行控制。
在堆中又分为了新生代和老年代,新生代又分为Eden空间、From Survivor空间、To Survivor空间。详细内容后面文章会详细讲解,这里不过多阐述。

6. 直接内存

直接内存也叫堆外内存,不属于JVM运行时数据区的一部分,主要通过DirectByteBuffer申请内存,该对象存在于堆中,包含了对堆外内存的引用;另外也可以通过Unsafe类或其它JNI手段直接申请内存。它的大小受限于本地内存的大小,也可以通过-XX:MaxDirectMemorySize设置,所以这一块也会出现OOM异常且较难排查。

字符串常量池

这个区域不是虚拟机规范中的内容,所有官方的正式文档中也没有明确指出有这一块,所以这里只是根据现象推导出结论。什么现象呢?有一个关于字符串对象的高频面试题:下面的代码究竟会创建几个对象?

String str = "abc";
String str1 = new string("cde");

我们先不管这个面试题,先来思考下面代码的输出结果是怎样的(以下试验基于JDK8,更早的版本结果会有所不同):

tring s1 = "abc";
 String s2 = "ab" + "c";
 String s3 = new String("abc");
 String s4 = new StringBuilder("ab").append("c").toString();
 System.out.println("s1 == s2:" + (s1 == s2));
 System.out.println("s1 == s3:" + (s1 == s3));
 System.out.println("s1 == s4:" + (s1 == s4));
 System.out.println("s1 == s3.intern:" + (s1 == s3.intern()));
 System.out.println("s1 == s4.intern:" + (s1 == s4.intern()));

输出结果如下:

s1 == s2:true
s1 == s3:false
s1 == s4:false
s1 == s3.intern:true
s1 == s4.intern:true

上面的输出结果和你想象的是否一样呢?为什么呢?一个个来分析。

  • s1 == s2:字面量“abc”会首先去字符串常量池找是否有"abc"这个字符串,如果有直接返回引用,如果没有则创建一个新对象并返回引用;s2你可能会觉得会创建"ab"、"c"和“abc”三个对象,但实际上首先会被编译器优化为“abc”,所以等同于s1,即直接从字符串常量池返回s1的引用。
  • s1 == s3:s3是通过new创建的,所以这个String对象肯定是存在于堆的,但是其中的char[]数组是引用字符创常量池中的s1,如果在这之前没有定义的话会先在常量池中创建“abc”对象。所以这里可能会创建一个或两个对象。
  • s1 == s4:s4通过StringBuilder拼接字符串对象,所以看起来理所当然的s1 != s4,但实际上也没那么简单,反编译上面的代码会可以发现这里又会被编译器优化为s4 = "ab" + "c"。猜猜这下会创建几个对象呢?抛开前面创建的对象的影响,这里会创建3个对象,因为与s2不同的是s4是编译器优化过后还存在“+”拼接,因此会在字符创常量池创建“ab”、"c"以及“abc”三个对象。前两个可以反编译看字节码指令或是通过内存搜索验证,而第三个的验证稍后进行。
  • s1 == s3.intern/s4.intern:这两个为什么是true呢?先来看看周志明在《深入理解Java虚拟机》书中说的:

使用String类的intern方法动态添加字符串常量到运行时常量池中(intern方法在1.6和1.7及以后的实现不相同,1.6字符串常量池放于永久代中,intern会把首次遇到的字符串实例复制永久代中并返回永久代中的引用,而1.7及以后常量池也放入到了堆中,intern也不会再复制实例,只是在常量池中记录首次出现的实例引用)。

上面的意思很明确,1.7以后intern方法首先会去字符串常量池寻找对应的字符串,如果找到了则返回对应的引用,如果没有找到则先会在字符串常量池中创建相应的对象。因此,上面s4和s4调用intern方法时都是返回s1的引用。
看到这里,相信各位读者基本上也都能理解了,对于开始的面试题应该也是心中有数了,最后再来验证刚刚说的“第三个对象”的问题,先看下面代码:

String s4 = new StringBuilder("ab").append("c").toString();
System.out.println(s4 == s4.intern());

这里结果是true。为什么呢?别急,再来看另外一段代码:

String s3 = new String("abc");
String s4 = new StringBuilder("ab").append("c").toString();

System.out.println(s3 == s3.intern());
System.out.println(s4 == s4.intern());

这里结果是两个false,和你心中的答案是一致的么?上文刚刚说了intern会先去字符串常量池找,找到则返回引用,否则在字符创常量池创建一个对象,所以第一段代码结果等于true正好说明了通过StringBuilder拼接的字符串会存到字符串常量池中;而第二段代码中,在StringBuilder拼接字符串之前已经优先使用new创建了字符串,也就会在字符串常量里创建“abc”对象,因此s4.intern返回的是该常量的引用,和s4不相等。你可能会说是因为优先调用了s3.intern方法,但即使你去掉这一段,结果还是一样的,也刚好验证了new String("abc")会创建两个对象(在此之前没有定义“abc”字面量,就会在字符串常量池创建对象,然后堆中创建String对象并引用该常量,否则只会创建堆中的String对象)。

总结

本文是JVM系列的开篇,主要分析JVM的运行时数据区、简单参数设置和字节码阅读分析,这也是学习JVM及性能调优的基础,读者需要深刻理解这些内容以及哪些区域会发生内存溢出(只有程序计数器不会内存溢出),另外关于运行时常量池和字符串常量池的内容也需要理解透彻。

到此这篇关于深入探究JVM之内存结构及字符串常量池的文章就介绍到这了,更多相关JVM内存结构字符串常量池内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • JVM内存结构相关知识解析

    最近在看< JAVA并发编程实践 >这本书,里面涉及到了 Java 内存模型,通过 Java 内存模型顺理成章的来到的 JVM 内存结构,关于 JVM 内存结构的认知还停留在上大学那会的课堂上,一直没有系统的学习这一块的知识,所以这一次我把< 深入理解Java虚拟机JVM高级特性与最佳实践 >.< Java虚拟机规范 Java SE 8版 >这两本书中关于 JVM 内存结构的部分都看了一遍,算是对 JVM 内存结构有了新的认识.JVM 内存结构是指:Java 虚拟机定义

  • JVM内存结构划分实例解析

    这篇文章主要介绍了JVM内存结构划分实例解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 数据区域划分 运行时内存区域划分:程序计数器.虚拟机栈.本地方法栈.堆.方法区 程序计数器 线程私有 通过寄存器实现 不会存在运行溢出 当前线程所执行的行号指示器,记住下一条JVM指令的执行地址 虚拟机栈 垃圾回收不涉及栈内存 栈内存是线程私有的,可以理解为线程运行需要的内存空间 栈由栈帧组成,每个栈帧代表一个方法执行时需要的内存(参数,局部变量,返回地

  • 深入解析JVM之内存结构及字符串常量池(推荐)

    前言 Java作为一种平台无关性的语言,其主要依靠于Java虚拟机--JVM,我们写好的代码会被编译成class文件,再由JVM进行加载.解析.执行,而JVM有统一的规范,所以我们不需要像C++那样需要程序员自己关注平台,大大方便了我们的开发.另外,能够运行在JVM上的并只有Java,只要能够编译生成合乎规范的class文件的语言都是可以跑在JVM上的.而作为一名Java开发,JVM是我们必须要学习了解的基础,也是通向高级及更高层次的必修课:但JVM的体系非常庞大,且术语非常多,所以初学者对此非

  • Java 中的字符串常量池详解

    Java中的字符串常量池 Java中字符串对象创建有两种形式,一种为字面量形式,如String str = "droid";,另一种就是使用new这种标准的构造对象的方法,如String str = new String("droid");,这两种方式我们在代码编写时都经常使用,尤其是字面量的方式.然而这两种实现其实存在着一些性能和内存占用的差别.这一切都是源于JVM为了减少字符串对象的重复创建,其维护了一个特殊的内存,这段内存被成为字符串常量池或者字符串字面量池.

  • Java中的字符串常量池详细介绍

    Java中字符串对象创建有两种形式,一种为字面量形式,如String str = "droid";,另一种就是使用new这种标准的构造对象的方法,如String str = new String("droid");,这两种方式我们在代码编写时都经常使用,尤其是字面量的方式.然而这两种实现其实存在着一些性能和内存占用的差别.这一切都是源于JVM为了减少字符串对象的重复创建,其维护了一个特殊的内存,这段内存被成为字符串常量池或者字符串字面量池. 工作原理 当代码中出现字

  • Java String 字符串常量池解析

    作为最基础的引用数据类型,Java 设计者为 String 提供了字符串常量池以提高其性能,那么字符串常量池的具体原理是什么,我们带着以下三个问题,去理解字符串常量池: 字符串常量池的设计意图是什么? 字符串常量池在哪里? 如何操作字符串常量池? 字符串常量池的设计思想 字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度地影响程序的性能 JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化 为字符串开辟一个字符串

  • C#之CLR内存字符串常量池(string)

    C#中的string是比特殊的类,说引用类型,但不存在堆里面,而且String str=new String("HelloWorld")这样的重装也说没有的. 我们先来看一个方法: class Program { static void Main(string[] args) { String s = "HelloWorld"; Console.WriteLine(s); } } 然后我们用ildasm.exe工具把它生成IL语言来看一看它里面是怎么玩的: .met

  • Java 常量池详解之字符串常量池实现代码

    目录 1.字符串常量池(String Constant Pool) 1.1:字符串常量池在Java内存区域的哪个位置? 1.2:字符串常量池是什么? 1.3 字符串常量池生成的时机? 如何将String对象放入到常量池 String 对象代码案例解析 new string(“abc”)创建了几个对象 解析public native String intern() 方法 Integer 对象代码案例解析 为啥Integer i1 =10 跟Integer.valueOf(10) 是相等的? 为啥I

  • Java字符串常量池示例详解

    为什么会有常量池的概念? 不知道小伙伴们是否有思考过这个问题? 没有思考也无所谓,小编在这里类比一下,大家就会清晰了.什么是池? 我们听的最多的池,应该是数据库连接池. 为什么会有数据库连接池,其实就是为了节省资源,提高性能,防止重复创建连接,避免占用内存和网络资源. 常量池其实就是跟数据库连接池的目的都是一样的.那么他是如何实现的呢? 因为常量池是JVM的概念,源码我们也不好看,所以我们还以连接池来类比. 池化的目标就是缓存和管理 稍微提一点池化的概念,其实就是对资源做一个包装,在包装层来加一

  • Java String类的理解及字符串常量池介绍

    目录 一. String类简介 1. 介绍 2. 字符串构造 二. 字符串常量池(StringTable) 1. 思考? 2. 介绍和分析 3. intern方法 三. 面试题:String类中两种对象实例化的区别 四. 字符串的不可变性 一. String类简介 1. 介绍 字符串广泛应用 在 Java 编程中,在 Java 中字符串属于对象,Java 提供了 String 类来创建和操作字符串. Java的String类在lang包里,java.lang.String是java字符串类,包含

  • JVM入门之内存结构(堆、方法区)

    目录 1.堆 1.1 定义 1.2 堆的作用 1.3 特点 1.4 堆内存溢出 1.5 堆内存诊断 2.方法区 2.1 结构(1.6 对比 1.8) 2.2 内存溢出 2.3 常量池 2.4 运行时常量池 2.5 常量池与串池的关系 2.6 StringTable的位置 2.7 StringTable 垃圾回收 2.8 方法区的垃圾回收 3.直接内存 释放原理 1.堆 1.1 定义 是Java内存区域中一块用来存放对象实例的区域[几乎所有的对象实例都在这里分配内存] 通过new关键字创建的对象都

  • 深入理解Java虚拟机 JVM 内存结构

    目录 前言 JVM是什么 JVM内存结构概览 运行时数据区 程序计数器 Java虚拟机栈 本地方法栈 方法区 运行时常量池 Java堆 直接内存 前言 JVM是Java中比较难理解和掌握的一部分,也是面试中被问的比较多的,掌握好JVM底层原理有助于我们在开发中写出效率更高的代码,可以让我们面对OutOfMemoryError时不再一脸懵逼,可以用掌握的JVM知识去查找分析问题.去进行JVM的调优.去让我们的应用程序可以支持更高的并发量等......总之一句话,学好JVM很重要! JVM是什么 J

随机推荐