前言

从本篇开始我们就要进入运行时数据区的方法区学习

image.png

一、栈、堆、方法区的交互关系


那么接下来我们从线程共享与否的角度来看运行时数据区看看是怎么样的?

image.png

下面就是看看栈、堆、方法区的交互关系是怎么样的?

image.png

从简单的代码角度出发,当前声明的变量对象是person,类型则是Person类。

针对于这个类型我们需要将它加载到方法区,我们new 的对象放入堆空间当中

接下来我们从栈堆方法区的内存结构来看看是怎么样的?

image.png

二、方法区的理解


可以进入官方文档查看更详细的介绍与了解:访问入口

方法区在哪里?

================================

《Java虚拟机规范》中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩

但对于HotSpotJVM而言方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开

方法区主要存放的是 Class,而堆中主要存放的是实例化的对象

image.png

方法区基本理解

================================

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域

多个线程同时加载统一个类时,只能有一个线程能加载该类,其他线程只能等等待该线程加载完毕,然后直接使用该类,即类只能加载一次

方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的

方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展

方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类导致方法区溢出

虚拟机同样会抛出内存溢出错误:

  • java.lang.OutofMemoryError:PermGen space (JDK 7 前)
  • java.lang.OutOfMemoryError:Metaspace(JDK 7 后)

我们可以使用一个示例并且打开Java VisualVM查看到底加载了多少个类

public class MethodAreaDemo {
    public static void main(String[] args) {
        System.out.println("start...");
        try {
           Thread.sleep(1000000);
        }catch(IhterruptedException e) {
            e.printStackTrace();
        }    
        System.out.println("end...");
    }
}

image.png

可见我们这么简单的代码,还是会加载一千多的类。那么当什么情况下加载太多的类会爆异常呢?

  • 加载大量的第三方的jar包
  • Tomcat部署的工程过多(30~50个)
  • 大量动态的生成反射类

当我们关闭JVM虚拟机的时候,就会释放这个区域的内存

Hotstop中方法区的演进

================================

在 JDK 7 及以前习惯上把方法区称为永久代。JDK 8 开始使用元空间取代了永久代

我们可以将方法区类比为Java中的接口,将永久代或元空间类比为Java中具体的实现类

本质上方法区和永久代并不等价,但仅对Hotspot而言的可以看作等价

《Java虚拟机规范》对如何实现方法区,不做统一要求

例如:BEAJRockit / IBM J9 中不存在永久代的概念

再比如在广东地区的人们喜欢将动物:狗称呼为旺财,在广东看来是等价的,但在别的地方并不等价

image.png

在 JDK 8 终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替

而元空间的本质和永久代本质有一些类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存

永久代、元空间二者并不只是名字变了,内部结构也调整了

根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常

三、设置方法区大小与OOM


image.png

在JVM规范中提到:方法区的大小不必是固定的,JVM可以根据应用的需要动态调整

JDK7及以前(永久代)设置

================================

  • 通过-XX:Permsize来设置永久代初始分配空间。默认值是20.75M
  • -XX:MaxPermsize来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M

当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError:PermGen space

我们将之前的那个类使用JDK7及以前的环境运行起来看看这个分配空间的大小

image.png

当我们运行起来后,在使用命令查看这个类看看是多少把

image.png

那么使用JDK 8 的环境下,是不能使用这两个参数的,在JVM 参数列表有说明该情况

image.png

JDK8及以后(元空间)设置

================================

元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 指定

默认值依赖于平台Windows下

  • -XX:MetaspaceSize 约为21M
  • -XX:MaxMetaspaceSize的值是-1,即没有限制

-XX:MetaspaceSize:对于一个 64位 的服务器端 JVM 来说,其默认的值为21MB。

这就是初始的高水位线一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置

新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值

我们将刚刚JDk 7 及以前的环境转为JDK 8 在运行起来看看

image.png

当我们运行起来后,在使用命令查看这个类看看是多少把

image.png

如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。

通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值

四、方法区的内部结构


接下来我们将介绍方法区的内部结构,就是方法区里到底存储的是什么信息?

请先看下图的简图

image.png

在《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等

image.png

接下来针对将这些信息详细展开内容看看具体是什么

类型信息:

对每个加载的类型(类class、接口interface、枚举enum、注解annotation)

JVM必须在方法区中存储以下类型信息:

  • 这个类型的完整有效名称(全名=包名.类名)
  • 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
  • 这个类型的修饰符(public,abstract,final的某个子集)
  • 这个类型直接接口的一个有序列表
域(属性)信息:

JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序

域的相关信息包括:域名称,域类型,域修饰符(public,private,protected,static,final,volatile,transient的某个子集)

方法信息:

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称
  • 方法的返回类型(包括 void 返回类型),void 在 Java 中对应的为 void.class
  • 方法参数的数量和类型(按顺序)
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
  • 异常表(abstract和native方法除外),异常表记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

接下来我们通过一个示例来查看具体的方法区是怎么记载的?

**
 * 测试方法区的内部构成
 */
public class MethodInnerStrucTest extends Object implements Comparable<String>,Serializable {
    //属性
    public int num = 10;
    private static String str = "测试方法的内部结构";
    //构造器
    //方法
    public void test1(){
        int count = 20;
        System.out.println("count = " + count);
    }
    public static int test2(int cal){
        int result = 0;
        try {
            int value = 30;
            result = value / cal;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }
    @Override
    public int compareTo(String o) {
        return 0;
    }
}

此时我们将该类进行编译:工具栏->Build->Recompild...InnerStrucTest.java

image.png

我们可以在编译后的out目录下找到对应的类,并且对Class文件右键选择Open in...

image.png

仅接着可以输入命令:javap -v -p MethodInnerStrucTest.class > test.txt

image.png

这时我们的命令会在当前相对路径下创建一个text.txt的文本保存信息

image.png

那么我们就可以打开该test.txt 文档查看关于类的信息了

当前我们展示这个类的类型信息

image.png

当前我们展示这个类的域信息

image.png

当前我们展示这个类的方法信息

image.png

image.png

刚刚我们举例了一个类并进行编译以及反编译查看方法区里对应的存放数据信息有哪些

那么在这之后我们再说明一个信息:non-final 类型的类变量

non-final 类型的类变量

================================

静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分

类变量被类的所有实例共享,即使没有类实例时,你也可以访问它

下面我们使用一个示例来体会一下static修饰的方法与字段

public class MethodAreaTest {
    public static void main(String[] args) {
        Order order = null;
        order.hello();
        System.out.println(order.count);
    }
}

class Order {
    public static int count = 1;
    public static final int number = 2;


    public static void hello() {
        System.out.println("hello!");
    }
}
//运行结果如下:
hello!
1

即使我们把order设置为null也不会出现空指针异常,表明了 static 类型的字段和方法随着类的加载而加载,并不属于特定的类实例

那么我们在static上在使用final 修饰呢?

全局常量:static final

================================

使用 static final 进行修饰我们称呼为:全局常量

这样声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了

那么我们根据刚刚的示例代码编译并查看一下是怎么样回事呢?

image.png

打开我们的text1.txt文档看看具体内容是什么呢?

image.png

运行时常量池:

image.png

在我们当前方法区的时候会有一个:运行时常量池,而在字节码文件内部包含了常量池

image.png

要想搞懂运行时常量池就要先搞懂字节码里的常量池

上面我们举例子并进行编译查看字节码文件的类型信息、域信息、方法信息

这些信息通过类加载器把我们刚刚提到的信息加载到方法区里面

那么我们字节码文件当中的常量池,把它加载到方法区结构里我们就称为运行时常量池

那么字节码文件的常量池在哪呢?长什么样子呢?

我们可以访问官方文档查看ClassFile主体结构长什么样样子:访问入口

image.png

那么我们使用图画的结构大概就是以下的图片那样

image.png

那么为什么需要提供一个常量池呢?常量池是干什么的呢?有什么用呢?

我们这里举一个代码的例子

public class SimpleClass {
    public void sayHello() {
        System.out.println("hello");
    }
}

当我们将它这源文件编译后产生一个字节码文件,大概只有194字节但是里面却使用了String、System、PrintStream及Object等结构。

若我们代码多的话,引用到的结构会更多。比如说我们这个文件中有6个地方用到了”hello”这个字符串。

如果不用常量池就需要在6个地方全写一遍,造成臃肿。

若我们使用常量池的话将”hello”等所需用到的结构信息记录在常量池中,使用的时候通过引用的方式,来加载、调用所需的结构

这就是为什么需要常量池了

常量池中有啥?

================================

  • 数量值
  • 字符串值
  • 类引用
  • 字段引用
  • 方法引用

我们可以根据前面的示例代码查看一下他的字节码,我们看看具体有哪些信息用到了

image.png

凡是 #3、#5、#13、#15等等这些带#的,都是引用了常量池。

image.png

image.png

image.png

我们可以将常量池可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型

那么运行时常量池是啥?

================================

我们刚刚介绍了一下常量池是什么,有什么东西。 接下来我们要介绍的是运行时常量池

我们说常量池表(Constant Pool Table)是Class字节码文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中

那么运行时常量池(Runtime Constant Pool)它是方法区的一部分

image.png

这时运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址

运行时常量池相对于Class文件常量池的另一重要特征是:具备动态性

运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些

但是当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutofMemoryError异常

五、方法区的使用案例


接下来我们通过一个使用案例来感受方法区的使用

public class MethodAreaDemo {
    public static void main(String[] args) {
        int x = 500;
        int y = 100;
        int a = x / y;
        int b = 50;
        System.out.println(a + b);
    }
}

我们将这段代码进行编译输出Class字节码文件

image.png
image.png

这时我们和上面的一样采用命令将当前文件写入txt文档当中

使用命令:javap -v -p MethodAreaDemo.class > test2. txt

image.png

接下来我们就可以打开查看这个类的字节码文件并开始进行分析起来了

image.png

image.png

那么对于main方法我们主要关注它的字节码指令执行过程是怎么样的,一起来分析看看吧
image.png

第二步程序计数器往下移,执行下一条字节码指令
image.png

第三步程序计数器往下移,执行下一条字节码指令
image.png

第四步程序计数器往下移,执行下一条字节码指令
image.png

第五步程序计数器往下移,执行下一条字节码指令
image.png

第六步程序计数器往下移,执行下一条字节码指令
image.png

第七步程序计数器往下移,执行下一条字节码指令
image.png

那么仅接着执行istore_3的操作,将我们计算结果:5,压入本地变量表当中里来

那么程序计数器接着往下移,相同操作我想应该是知道是什么意思了

我们接着执行第八步,程序计数器往下移至15,执行下一条字节码指令

image.png

image.png

刚刚我们执行计算的时候,将计算结果:5放入本地变量表的索引:3中了

这时我们程序计数器往下移,执行下一条字节码指令

image.png

第九步程序计数器往下移,执行下一条字节码指令

image.png

第十步程序计数器往下移,执行下一条字节码指令

image.png

第十一步程序计数器往下移,执行下一条字节码指令
image.png

这时若是没有其他的操作,则return 返回
image.png

上面代码调用 System.out.println() 方法时,首先需要看看 System 类有没有加载,再看看 PrintStream 类有没有加载

如果没有加载,则执行加载,执行时,将常量池中的符号引用(字面量)转换为运行时常量池的直接引用(真正的地址值)

六、方法区演进细节


首先明确:只有Hotspot才有永久代BEA JRockit、IBMJ9等来说,是不存在永久代的概念的

原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一

那么我们就说说Hotspot中方法区的变化:

image.png

接下来分开使用图画的方式分别讲解JDK6、JDK7、JDk8及以后

JDk 6 示意图如下:

image.png

JDk 7 示意图如下:

image.png

JDk 8 示意图如下:

image.png

永久代为什么要被元空间替代?

================================

我们之前也说过可以设置虚拟机的内存,但是这个大小是很难确定的。

在某些场景下如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中因为功能点比较多在运行过程中,要不断动态加载很多类,也会经常出现致命错误

因此我们将这些元空间不设置在虚拟机中而是使用本地内存,这样仅受本地内存限制

并且我们之前对永久待进行调优是比较困难的,对于方法区的垃圾收集主要回收两部分内容:

  • 常量池中废弃的常量
  • 不再用的类型

HotSpot虚拟机对常量池的回收策略:只要常量池中的常量没有被任何地方引用,就可以被回收

那么对于一个类型是否属于“不再被使用的类”的条件就比较苛刻了需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
  • 加载该类的类加载器已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用并通过反射访问该类的方法

类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等否则通常是很难达成的

字符串常量池 StringTable 为什么要调整位置?

================================

JDK7中将StringTable放到了堆空间中。

但是永久代的回收效率很低,在Full GC的时候才会执行永久代的垃圾回收,而Full GC是老年代的空间不足、永久代不足时才会触发

这就导致StringTable回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。所以调整到放到堆里,能及时回收内存

谈谈静态变量放在哪呢?

================================

我们使用示例代码去说说这个事情,代码块如下:

public class StaticFieldTest {

    private static byte[] arr = new byte[1024 * 1024 * 100];//100MB

    public static void main(String[] args) {
        System.out.println(StaticFieldTest.arr);
    }
}

我们先看看JDk 7 及以前的环境到底是怎么样的

image.png
image.png

我们先看看JDk 8 的环境到底是怎么样的
image.png
image.png

我们可以知道对象的本身始终是在堆空间的,不同的是指向这个对象的变量本身的位置不同

使用JHSDB工具来进行分析

================================

这个工具是JDK9开始自带的(JDK9以前没有),在bin目录下可以找到

image.png

接下来我们使用示例演示工具的分析

public class StaticObjTest {
    static class Test {
        static ObjectHolder staticObj = new ObjectHolder();
        ObjectHolder instanceObj = new ObjectHolder();
        void foo() {
            ObjectHolder localObj = new ObjectHolder();
            System.out.println("done");
        }
    }
    
    private static class ObjectHolder { }
    
    public static void main(String[] args) {
        Test test = new StaticObjTest.Test();
        test.foo();
    }
}

我们的问题是:staticObj、instanceObj、localObj变量存放在哪里?本身存放在哪里?

image.png

根据前面我们学习的局部变量、实例对象、以及类变量的知识,我们可以得出

  • localObject则是存放在foo()方法栈帧的局部变量表中
  • instanceObj随着Test的对象实例存放在Java堆
  • staticObj随着Test的类型信息存放在方法区

那么接着我们会在一个Java.lang.Class的实例里找到一个引用该staticObj对象的地方

image.png

那么在我们的《Java虚拟机规范》所定义的概念模型来看,所有Class相关的信息都应该存放在方法区之中,但方法区该如何实现《Java虚拟机规范》并未做出规定,这就成了一件允许不同虚拟机自己灵活把握的事情

而我们的JDK7及其以后版本的HotSpot虚拟机选择把静态变量与类型在Java语言一端的映射Class对象存放在一起存储于Java堆之中,从我们的实验中也明确验证了这一点

七、方法区的垃圾回收


有些人认为方法区(如Hotspot虚拟机中的元空间或者永久代)是没有垃圾收集行为的

其实不然,在《Java虚拟机规范》中对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。

事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)

一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。

以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏

我们刚刚说方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型

先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用

字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等

而符号引用则属于编译原理方面的概念,包括下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

而对于不再使用类型我们前面也说到了,条件比较苛刻,需要同时满足下面是三个条件

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
  • 加载该类的类加载器已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用并通过反射访问该类的方法

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

那么我们关于是否要对类型进行回收HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class 以及 -XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力

八、运行时数据区总结


我们前面也说到了线程私有的地方有:程序计数器、本地方法栈、虚拟机栈

image.png

而线程共有的地方有:方法区、堆空间,并且方法区是hotstop 虚拟机才有的

image.png

九、直接内存


我们这里说的直接内存不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。

我们说的直接内存是在Java堆外的、直接向系统申请的内存区间。来源于NIO通过存在堆中的DirectByteBuffer操作Native内存

通常访问直接内存的速度会优于Java堆,即读写性能高

  • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
  • Java的NIO库允许Java程序使用直接内存,用于数据缓冲区

接下来我们举一个例子来体会一下直接内存

public class BufferTest {
    private static final int BUFFER = 1024 * 1024 * 1024;//1GB

    public static void main(String[] args){
        //直接分配本地内存空间
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
        System.out.println("直接内存分配完毕,请求指示!");

        Scanner scanner = new Scanner(System.in);
        scanner.next();

        System.out.println("直接内存开始释放!");
        byteBuffer = null;
        System.gc();
        scanner.next();
    }
}

当我们运行起该类的时候,再打开任务管理器查看一下我们这个直接内存的使用

image.png

这时我们就可以知道DirectByteBuffer会直接操作本地内存的空间

BIO与NIO

================================

传统的架构在读写本地文件时,我们需要从用户态切换成内核态

image.png

当我们使用NIO 时会直接操作物理磁盘,省去了中间过程

image.png

直接内存与OOM

================================

直接内存也可能导致OutofMemoryError异常,因为由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的.Java堆和直接内存的总和依然受限于操作系统能给出的最大内存

直接内存的缺点为:

  • 分配回收成本较高
  • 不受JVM内存回收管理

直接内存大小可以通过MaxDirectMemorySize设置,如果不指定默认与堆的最大值-Xmx参数值一致

image.png

参考资料


尚硅谷:JVM虚拟机(宋红康老师)


28640
116 声望25 粉丝

心有多大,舞台就有多大