深入探究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,更早的版本结果会有所不同):

 String 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及性能调优的基础,读者需要深刻理解这些内容以及哪些区域会发生内存溢出(只有程序计数器不会内存溢出),另外关于运行时常量池和字符串常量池的内容也需要理解透彻。

原文地址:https://www.cnblogs.com/yewy/p/13353167.html

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


jinfo 命令可以用来查看 Java 进程运行的 JVM 参数,命令如下:[root@admin ~]# jinfo --helpUsage: jinfo [option] &lt;pid&gt; (to connect to running process) jinfo [option] &lt;executable &lt;core&gt; (to connect to a core file) jinfo [option] [serve
原文链接:https://www.cnblogs.com/niejunlei/p/5987611.htmlJava Virtual Machine Stacks,线程私有,生命周期与线程相同,描述的是Java方法执行的内存模型:每一个方法执行的同时都会创建一个栈帧(Stack Frame),由于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法的执行就对应着栈帧在虚拟机栈中的入栈,出栈...
java 语言, 开发者不能直接控制程序运行内存, 对象的创建都是由类加载器一步步解析, 执行与生成与内存区域中的; 并且jvm有自己的垃圾回收器对内存区域管理, 回收; 但是我们已经可以通过一些工具来在程序运行时查看对应的jvm内存使用情况, 帮助更好的分析与优化我们的代码;jps查看系统中有哪些java进程jps 命令类似与 linux 的 ps 命令,但是它只列出系统中所有的 Java 应用程序。 通过 jps 命令可以方便地查看 Java 进程的启动类、传入参数和 Java 虚拟机参数等信息
1.jvm的简单抽象模型:  2.类加载机制     双亲委派模型是为了防止jdk核心类库被篡改,如果需要打破可以重写Classloader.loadClass方法。r 双亲委派模型:一个类加载器收到一个类的加载请求,他会先判断自身是否已存在该类,如果不存在上抛给上一级类加载器ClassLoad
堆外内存JVM启动时分配的内存,称为堆内存,与之相对的,在代码中还可以使用堆外内存,比如Netty,广泛使用了堆外内存,但是这部分的内存并不归JVM管理,GC算法并不会对它们进行回收,所以在使用堆外内存时,要格外小心,防止内存一直得不到释放,造成线上故障。堆外内存的申请和释放JDK的ByteBuffe
1.springboot和tomcat2.springcloud的请求如何通过网关鉴权?3.springmvc启动时组件的加载顺序?4.mybatis如何同时更新三条记录5.hibernate实现级联更新6.一个web程序应用程序启动时的加载流程7.如何向www.baidu.com地址发出请求时,并获取相应?8.???9.谈谈你对tcp/iptelnetudp协
堆设置-Xms256M:初始堆大小256M,默认为物理内存的1/64-Xmx1024M:最大堆大小1024M,默认为物理内存的1/4,等于与-XX:MaxHeapSize=64M-Xmn64M:年轻代大小为64M(JDK1.4后支持),相当于同时设置NewSize和MaxNewSize为64M-XX:NewSize=64M:初始年轻代大小-XX:MaxNewSize=256M:最大年轻代大小(默认
一.概述收集算法(JVM之垃圾回收-垃圾收集算法)是内存回收的抽象策略,垃圾收集器就是内存回收的具体实现。JVM规范对于垃圾收集器的应该如何实现没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器差别较大,这里只看HotSpot虚拟机。就像没有最好的算法一样,垃圾收集器
Java中的堆是JVM所管理的最大的一块内存空间,主要用于存放各种类的实例对象,如下图所示: 在Java中,堆被划分成两个不同的区域:新生代(Young)、老年代(Old)。新生代(Young)又被划分为三个区域:Eden、S0、S1。 这样划分的目的是为了使JVM能够更好的管理堆内存中的对象,包
JVM深入理解JVM(4)——如何优化JavaGC「译」 PostedbyCrowonAugust21,2017本文翻译自SangminLee发表在Cubrid上的”BecomeaJavaGCExpert”系列文章的第三篇《HowtoTuneJavaGarbageCollection》,本文的作者是韩国人,写在JDK1.8发布之前,虽然有些地
 JVM深入理解JVM(2)——GC算法与内存分配策略 PostedbyCrowonAugust10,2017说起垃圾收集(GarbageCollection,GC),想必大家都不陌生,它是JVM实现里非常重要的一环,JVM成熟的内存动态分配与回收技术使Java(当然还有其他运行在JVM上的语言,如Scala等)程序员在提升开
运行时数据区  线程独有本地方法栈、虚拟机栈、程序计数器这些与线程对应的数据区会随着线程开始和结束创建和销毁  整体公有元数据区(又称方法区)、堆区会随着虚拟机启动而创建,随着虚拟机退出而销毁 
java整个堆大小设置:Xmx和Xms设置为老年代存活对象的3-4倍,即FullGC之后的老年代内存占用的3-4倍。永久代PermSize和MaxPermSize设置为老年代存活对象的1.2-1.5倍年轻代Xmx的设置为老年代存活对象的1-1.5倍老年代的内存大小设置为老年代存活对象的2-3倍BTW: Sun官方建议年轻代
栈顶缓存(Top-of-StackCashing)技术基于栈式架构得虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派次数和内存读写次数 由于操作数是存储在内存重的,因此频繁地执行内存读/写操作必然影响速度。 综上
自用。同样的代码在不同的平台生成的机器码是不一样的,为什么java代码生成的字节码文件,能在不同的平台运行?因为不同版本的jdk里面的虚拟机会屏蔽不同操作系统在底层硬件与指令上的区别。栈:线程栈,局部变量存放栈内存区域。线程(分配一个栈)运行分配栈将局部变量放入内存。怎么放:栈
jconsole监控:1.java启动命令加上参数java-Djava.rmi.server.hostname=172.16.17.247-Dcom.sun.management.jmxremote-Dcom.sun.management.jmxremote.port=2099-Dcom.sun.management.jmxremote.authenticate=false-Dcom.sun.management.jmxremote.ssl=false -XX:+Unlock
类加载器分类publicclassStackStruTest{publicstaticvoidmain(String[]args){//对用户自定义个类来说:默认使用系统类加载器进行加载-----AppClassLoaderClassLoaderclassLoader=StackStruTest.class.getClassLoader();System.out.p
堆体系结构一个JVM实例只存在一个堆内存,堆内存的大小是可调节的。类加载器读取类文件后,需要把类、方法、常量、变量放在堆内存中,保存所有引用类型的真实信息,以方便执行器指向,堆内存分为三个部分:年轻代、老年代、永久代。Java7之前,堆内存在逻辑上分为:年轻代、老年代、永久代。物
JVM深入理解JVM(5)——虚拟机类加载机制 PostedbyCrowonAugust21,2017在Class文件中描述的各种信息,最终都需要加载到虚拟机中之后才能运行和使用。而虚拟机中,而虚拟机如何加载这些Class文件?Class文件中的信息进入到虚拟机中会发生什么变化?本文将逐步解答这
保存(持久化)对象及其状态到内存或者磁盘Java平台允许我们在内存中创建可复用的Java对象,但一般情况下,只有当JVM处于运行时,这些对象才可能存在,即,这些对象的生命周期不会比JVM的生命周期更长。但在现实应用中,就可能要求在JVM停止运行之后能够保存(持久化)指定的对象,并在