JVM--方法区理解

一、简介

方法区在JVM中也是一个非常重要的区域,它与堆一样,是被线程共享的区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。

方法区(method area)只是JVM规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,具体放在哪里,不同的实现可以放在不同的地方。而永久代Hotspot虚拟机特有的概念,是方法区的一种实现,别的JVM都没有这个东西。

二、方法区结构

2.1、先看classLoader是如何加载class文件和存储文件信息的

当一个classLoder启动的时候,classLoader的生存地点在jvm中的堆,然后它会去主机硬盘上将A.class装载到jvm的方法区,方法区中的这个字节文件会被虚拟机拿来new A字节码(),然后在堆内存生成了一个A字节码的对象,然后A字节码这个内存文件有两个引用一个指向A的class对象,一个指向加载自己的classLoader。那么方法区中的字节码内存块,除了记录一个class自己的class对象引用和一个加载自己的ClassLoader引用之外,还记录了什么信息呢??见下图:

2.2、方法区关键信息介绍

    1.类信息:修饰符(public final)

                        是类还是接口(class,interface)

                        类的全限定名(Test/ClassStruct.class)

                        直接父类的全限定名(java/lang/Object.class)

                        直接父接口的权限定名数组(java/io/Serializable)

        也就是 public final class ClassStruct extends Object implements Serializable这段描述的信息提取

       2.字段信息:修饰符(pirvate)

                            字段类型(java/lang/String.class)

                            字段名(name)

          也就是类似private String name;这段描述信息的提取

       3.方法信息:修饰符(public static final)

                          方法返回值(java/lang/String.class)

                          方法名(getStatic_str)

                          参数需要用到的局部变量的大小还有操作数栈大小(操作数栈我们后面会讲)

                          方法体的字节码(就是花括号里的内容)

                          异常表(throws Exception)

         也就是对方法public static final String getStatic_str ()throws Exception的字节码的提取
       4.常量池:

                    4.1.直接常量:

                                   1.1CONSTANT_INGETER_INFO整型直接常量池public final int CONST_INT=0;

                                   1.2CONSTANT_String_info字符串直接常量池   public final String CONST_STR="CONST_STR";

                                   1.3CONSTANT_DOUBLE_INFO浮点型直接常量池

                                   等等各种基本数据类型基础常量池(待会我们会反编译一个类,来查看它的常量池等。)

                     4.2.方法名、方法描述符、类名、字段名,字段描述符的符号引用

            也就是所有编译器能够被确定,能够被快速查找的内容都存放在这里,它像数组一样通过索引访问,就是专门用来做查找的。

            编译时就能确定数值的常量类型都会复制它的所有常量到自己的常量池中,或者嵌入到它的字节码流中。作为常量池或者字节码流的一部分,编译时常量保存在方法区中,就和一般的类变量一样。但是当一般的类变量作为他们的类型的一部分数据而保存的时候,编译时常量作为使用它们的类型的一部分而保存

      5.类变量(静态变量):

                  就是静态字段( public static String static_str="static_str";)

                  虚拟机在使用某个类之前,必须在方法区为这些类变量分配空间。

      6.一个到classLoader的引用,通过this.getClass().getClassLoader()来取得为什么要先经过class呢?思考一下,看一下上面的图,再回来思考。(class A 对象拥有A字节码和加载它的加载器地址引用)

      7.一个到class对象的引用,这个对象存储了所有这个字节码内存块的相关信息。所有你能够看到的区域,比如:类信息,你可以通过this.getClass().getName()取得

          所有的方法信息,可以通过this.getClass().getDeclaredMethods(),字段信息可以通过this.getClass().getDeclaredFields(),等等,所有在字节码中你想得到的,调用的,通过class这个引用基本都能够帮你完成。因为他就是字节码在内存块在堆中的一个对象

      8.方法表,如果学习c++的人应该都知道c++的对象内存模型有一个叫虚表的东西,java本来的名字就叫c++- -,它的方法表其实说白了就是c++的虚表,它的内容就是这个类的所有实例可能被调用的所有实例方法的直接引用。也是为了动态绑定的快速定位而做的一个类似缓存的查找表,它以数组的形式存在于内存中。不过这个表不是必须存在的,取决于虚拟机的设计者,以及运行虚拟机的机器是否有足够的内存。

三、方法区与永久代的关系

        很多文章里喜欢把方法区等同与永久代,永久代既然没了,方法区也就没了。但我认为方法区只是一种逻辑上的概念,永久代指物理上的堆内存的一块空间,这块实际的空间完成了方法区存储字节码、静态变量、常量的功能等等。既然如此,现在元空间也可以认为是新的方法区的实现了。

方法区也叫永久代。在过去(自定义类加载器还不是很常见的时候),类大多是”static”的,很少被卸载或收集,因此被称为“永久的(Permanent)”。虽然Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java 堆区分开来。同时,由于类class是JVM实现的一部分,并不是由应用创建的,所以又被认为是“非堆(non-heap)”内存。HotSpot 虚拟机的设计团队选择把GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机(如BEA JRockit、IBM J9 等)来说是不存在永久代的概念的。

  永久代也是各个线程共享的区域,它用于存储已经被虚拟机加载过的类信息,常量,静态变量(JDK7中被移到Java堆),即时编译期编译后的代码(类方法)等数据。这里要讲一下运行时常量池,它是方法区的一部分,用于存放编译期生成的各种字面量和符号引用(其实就是八大基本类型的包装类型和String类型数据(JDK7中被移到Java堆))(官方文档说明: In JDK 7, interned strings are no longer allocated in the permanent generation of the Java heap, but are instead allocated in the main part of the Java heap (known as the young and old generations), along with the other objects created by the application)。

  在JDK1.7中的HotASpot中,已经把原本放在方法区的字符串常量池移到java heap中。

  • 将interned String移到Java堆中
  • 将符号Symbols移到native memory(不受GC管理的内存)

  从JDK7开始永久代的移除工作,贮存在永久代的一部分数据已经转移到了Java Heap或者是Native Heap。但永久代仍然存在于JDK7,并没有完全的移除:符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。随着JDK8的到来,JVM不再有PermGen。但类的元数据信息(metadata)还在,只不过不再是存储在连续的堆空间上,而是移动到叫做“Metaspace”的本地内存(Native memory)中。

四、jdk1.6,jdk1.7,jdk1.8变化

4.1常量池主要可以分为以下几种:

(1)静态常量池:即*.class文件中的常量池,class文件中的常量池不仅仅包含字符串/数字这些字面量,还包含类、方法的信息,占用class文件绝大部分空间。这种常量池主要用于存放两大类常量:字面量和符号引用量,字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等;符号引用则属于编译原理    方面的概念,包括了如下三种类型的常量:类和接口的全限定名、字段名称描述符、方法名称描述符。

        类的加载过程中的链接部分的解析步骤就是把符号引用替换为直接引用,即把那些描述符(名字)替换为能直接定位到字段、方法的引用或句柄(地址)。

  • Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用。
  • 字面量(Literal):文本字符串(如String str = "SpiderLucas"中SpiderLucas就是字面量)、八种基本类型的值(如int i = 0中0就是字面量)、被声明为final的常量等;
  • 符号引用(Symbolic References):类和方法的全限定名、字段的名称和描述符、方法的名称和描述符。
  • 每个class文件都有一个class常量池。

(2)运行时常量池:虚拟机会将各个class文件中的常量池载入到运行时常量池中,即编译期间生成的字面量、符号引用,总之就是装载class文件。 

(3)字符串常量池 :字符串常量池可以理解为运行时常量池分出来的部分。加载时,对于class的静态常量池,如果字符串会被装到字符串常量池中。

(4)整型常量池:Integer,类似字符串常量池,可以理解为运行时常量池分出来的。加载时,对于class的静态常量池装的东西,如果是整型会被装到整型常量池中。 类似的还有Character、Long等等常量池(基本数据类型没有哦)。。。

在永久代移除后,字符串常量池也不再放在永久代了,但是也没有放到新的方法区---元空间里,而是留在了堆里(为了方便回收?)。运行时常量池当然是随着搬家到了元空间里,毕竟它是装静态变量、字节码等信息的,有它的地方才称得上方法区。

4.2 字符串常量池从1.7以后,移到了heap中。(1.6在永久代,1.7以后移动到了heap中)。

4.2.1intern()定义及使用

相信绝大多数的人不会去用String类的intern方法,打开String类的源码发现这是一个本地方法,定义如下: 

public native String intern(); 

文档告诉我们该方法返回一个字符串对象的内部化引用。关于native方法详解见native关键字(本地方法)、 java调用so动态链接库

java.lang.String.intern():返回一个保留池字符串,就是一个在全局字符串池中有了一个入口。如果以前没有在全局字符串池中,那么它就会被添加到里面。

例如:

一个初始时为空的字符串池,它由类 String 私有的维护。 当调用 intern 方法时,如果池已经包含一个等于此 String 对象的字符串(该对象由 equals(Object) 方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并且返回此 String 对象的引用。 它遵循对于任何两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。

总结出来其意思如下:如果:s.intern()方法的时候,会将共享池中的字符串与外部的字符串(s)进行比较,如果共享池中有与之相等的字符串,则不会将外部的字符串放到共享池中的,返回的只是共享池中的字符串,如果不同则将外部字符串放入共享池中,并返回其字符串的句柄(引用)-- 这样做的好处就是能够节约空间


众所周知:String类维护一个初始为空的字符串的对象池,当intern方法被调用时,如果对象池中已经包含这一个相等的字符串对象则返回对象池中的实例,否则添加字符串到对象池并返回该字符串的引用。 
   
   从程序的角度上怎么来看这个方法呢,我们假设有两个字符串s1,s2,当s1.equals(s2)时,s1.intern()==s2.intern(),也就是说这两个字符串在内存中使用的是同一个实例。 
   
   Java语言规范中定义了字符串文字以及更一般的常量表达式的值的字符串是被内部化的,以便它们共享同一个实例。我们试验一下下面代码:

String s1="你好,Java"; 
String s2="你好,"+"Java"; 
System.out.println(s1==s2); 
System.out.println(s1.intern()==s2.intern());

这段代码将打印两个true,也就是说字符串s1和s2是共享同一个实例。不过前提是尽管使用了表达式,但是表达式中必须都是常量。 
   
  了解这个处理机制也可以让我们在用到字符串常量的时候了解如何节省这些字符串所占用的内存。 

下面两个例子可以帮你: 

public void inTest1() {

        System.out.println("begin inTest1()==========");
        String a = "b";
        String b = "b";

        System.out.println(a == b); //true

        String c = "d";
        String d = new String("d").intern();
        System.out.println(c == d); //true
        System.out.println("end inTest1()==========");
    }

    public void inTest2() {
        System.out.println("begin inTest2()==========");
        String a = new String("abc");
        String b = new String("ab");
        b = b + "c";
        System.out.println(a == b);//false
        System.out.println(a == b.intern());//false
        System.out.println(a.intern() == b);//false
        System.out.println(a.intern() == b.intern()); //true
        System.out.println("end inTest2()==========");
    }

 

4.2.2 字符串常量池

1、 字面量和常量池初探

字符串对象内部是用字符数组存储的,那么看下面的例子:

    String m = "hello,world";
    String n = "hello,world";
    String u = new String(m);
    String v = new String("hello,world");
  1. 会分配一个11长度的char数组,并在常量池分配一个由这个char数组组成的字符串,然后由m去引用这个字符串

  2. 用n去引用常量池里边的字符串,所以和m引用的是同一个对象

  3. 生成一个新的字符串,但内部的字符数组引用着m内部的字符数组

  4. 同样会生成一个新的字符串,但内部的字符数组引用常量池里边的字符串内部的字符数组,意思是和u是同样的字符数组

使用图来表示的话,情况就大概是这样的(使用虚线只是表示两者其实没什么特别的关系):

测试demo:

String m = "hello,world";
String n = "hello,world";
String u = new String(m);
String v = new String("hello,world");
            
System.out.println(m == n); //true 
System.out.println(m == u); //false
System.out.println(m == v); //false
System.out.println(u == v); //false

 结论:

  • m和n是同一个对象

  • m,u,v都是不同的对象

  • m,u,v,n但都使用了同样的字符数组,并且用equal判断的话也会返回true

2、常量池实现及优化

保留字符串不能随意进行,但是如果有大量重复的字符串,占据了很大一部分堆,这时就很有效果了。保留字符串的表是保存在原生内存中的,它是一个大小固定的Hashtable。在java7u40之前的版本中,这个表默认有1009个桶,平均而言,在因为链接而出现冲突之前,预计可以保存500个字符串。在64位版本的java7u40及更新的版本中,默认大小为60013。

从java7开始,这个表(保留字符串表)大小可以在JVM启动时使用-XX:StringTableSize=N(默认1009或60013)。如果某个应用会保留大量字符串,就应该增加这个值。如果这个值是个素数,字符串保留表的效率最高。

intern()方法的性能是由表大小的调优程度所决定的。在《java性能权威指南》中有个示例:表7-3列出了在不同场景下创建和保留1千万个随机创建的字符串的总时间:

注意,如果字符串保留表的大小设置不当,性能损失会相当严重。一旦根据预期数据设置了该表的大小,性能会极大改善。

某个应用中已经分配的保留字符串个数(及其总大小),可以使用如下的jmap命令获得(这也需要JDK 7u6或者更新版本):

[ciadmin@2-103test_app ~]$ jmap -heap 26964
Attaching to process ID 26964, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.131-b11
...
Heap Usage:
PS Young Generation
Eden Space:
   capacity = 64487424 (61.5MB)
   used     = 44652520 (42.583961486816406MB)
   free     = 19834904 (18.916038513183594MB)
   69.24221379970147% used
From Space:
   capacity = 1572864 (1.5MB)
   used     = 1048928 (1.000335693359375MB)
   free     = 523936 (0.499664306640625MB)
   66.68904622395833% used
To Space:
   capacity = 1572864 (1.5MB)
   used     = 0 (0.0MB)
   free     = 1572864 (1.5MB)
   0.0% used
PS Old Generation
   capacity = 456130560 (435.0MB)
   used     = 74748280 (71.28551483154297MB)
   free     = 381382280 (363.71448516845703MB)
   16.387474673917925% used

29515 interned Strings occupying 3619152 bytes.
[ciadmin@2-103test_app ~]$

如果将字符串表设得特别大,其损失是非常小的:每个桶只需要4字节或8字节(取决于使用的是32位还是64位的JVM),所以比最优的情况多几千,只是一次性消耗一些原生内存(不是堆内存)。

另外,如果想看看字符串标的执行过程,可以使用-XX:+PrintStringTableStatistics参数,在JVM退出时,它会打印一个这样的列表:

这个例子有3035072个保留字符串(因为有1009个桶,每个桶平均有3008个字符串,1009*3008=3035072)。理想情况下,桶的平均大小应该是0或1.这个大小实际上不会为0,可能会小于0.5,但是因为技术时用的是整形运行,所以报告中会向下取整。(不理解)

3、常量池存放区域变化

总结:jdk1,6常量池放在方法区(也即是Perm空间),jdk1.7,jdk1.8常量池放在堆内存。所以导致string的intern方法因为以上变化在不同版本会有不同表现。

下面,我们通过测试程序来窥探字符串常量池在Java6,Java7两个不同版本底下的内存分配情况。

package com.dxz.metaspace;

public class StringPoolTest {
    public void testStringPoolWithLongString() {
        long i = 0;
        while (true) {
            String longString = "This is a very long string, very very long string to test the gc behavior of the string constant pool"
                    + i;
            longString.intern();
            i++;
        }
    }

    public static void main(String[] args) {
        StringPoolTest stringPoolTest = new StringPoolTest();
        stringPoolTest.testStringPoolWithLongString();
    }
}

测试程序很简单,一个死循环,循环里面通过递增变量i制造唯一的字符串,然后用main函数启动程序。

Java 6

我们使用版本Jdk1.6.0_29来跑该程序,打开Java VisualVM监控,可以看到,Perm区不断发生GC,由此的出结论,虽然字符串常量池放在Perm空间,但当Perm空间接近满的时候,JVM会将字符串常量池中的无用字符串回收掉。

深入理解 Java String#intern() 内存模型

Java 7

下面,我们切换到Jdk1.7.0_67重跑该程序,可以看到Perm区内存分配曲线很平滑,没有出现内存分配的现象。

深入理解 Java String#intern() 内存模型

但在Heap空间,新的对象不断产生,然后不断触发GC

深入理解 Java String#intern() 内存模型

示例2

  其实,移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。我们可以通过一段程序来比较 JDK 1.6 与 JDK 1.7及 JDK 1.8 的区别,以字符串常量为例:

package com.dxz.jvm;
import java.util.ArrayList;
import java.util.List;
 
public class StringOomMock {
    static String base = "String";
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        for (int i=0;i< Integer.MAX_VALUE;i++){
            String str = base + i;
            list.add(str.intern());
        }
    }
}

这段程序以2的指数级不断的生成新的字符串,这样可以比较快速的消耗内存。我们通过 JDK 1.6、JDK 1.7 和 JDK 1.8 分别运行:

JDK 1.6 的运行结果:

JVM参数设置如下:

-XX:MaxPermSize=4M
-Xmx512M
-verbose -verbose:gc

JDK 1.7的运行结果:

-XX:MaxPermSize=4M
-Xmx42M
-verbose -verbose:gc

JDK 1.8的运行结果:

-XX:MaxMetaspaceSize=8M
-XX:PermSize=8m
-XX:MaxPermSize=8m

  从上述结果可以看出,JDK 1.6下,会出现“PermGen Space”的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。(上面日志中的最后两行)因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论。现在我们看看元空间到底是一个什么东西?

  元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:

  -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
  -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。

  除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
  -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

现在我们在 JDK 8下重新运行一下代码段 4,不过这次不再指定 PermSize 和 MaxPermSize。而是指定 MetaSpaceSize 和 MaxMetaSpaceSize的大小。输出结果如下:

从输出结果,我们可以看出,这次不再出现永久代溢出,而是出现了元空间的溢出。

 

五、垃圾回收

jdk1.6

常量池如何触发的垃圾回收?
Class文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量表(constant_pool table),用于存放编译期已可知的常量,这部分内容将在类加载后进入方法区(永久代)存放。但是Java语言并不要求常量一定只有编译期预置入Class的常量表的内容才能进入方法区常量池,运行期间也可将新内容放入常量池(最典型的String.intern()方法)。

GC的作用主要是用来卸载类和回收常量池,当然有部分方法区,即使永久代(Perm Space)也会一定的回收

jdk1.7,1.8

Metaspace 垃圾回收:

对于僵死的类及类加载器的垃圾回收将在元数据使用到“MaxMetaspaceSize”参数的设定值时进行。 
适时地监控和调整元空间对于减小垃圾回收频率和减少延时是很有必要的。持续的元空间垃圾回收说明,可能存在类、类加载器导致的内存泄漏或是大小设置不合适。

 

 

 

 

 

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值