简单去记录一下 Java 这门语言在运行时候内存中的样子,让自己以后开发和安全上更能得心应手。如题也是浅谈,Java 关于内存的设计根本也不是一篇 markDown 就可以记录完的,这里主要谈的是 JDK8 以后的样子,因为 JDK7 和 JDK8 这两版对 Java 内存分配的机制有些改动。文章结构是先去说明一下大概,然后去细分介绍每一个部分,最后对照实例代码去阐述过程。
Java 8 内存结构
给一张非常棒的图
大体上来看,在 Java 8 中内存块可以分为堆、栈、方法区这三大块,然后可以再向下细分,堆中包含静态变量(通过 static 去修饰的东西),实例,字符串常量池(不是常量池);栈又可分为 JVM 栈和本地方法栈;方法区里面有已被虚拟机加载的类信息、字段信息、方法信息(包括 static 方法)等,也就是常量池。把上面的这张图给精简一下如下:
深入介绍
顺序就按照栈–>堆–>方法区来逐一介绍里面的每一个部分
栈
在任何高级语言中栈的作用无非就是两个,一个是存储局部变量或者参数,另一个就是去调用函数(存函数的地址),在 Java 中也不例外,在 Java 中栈是由一个一个的栈针组成的,每一个栈针的格式如下,其中包含局部变量表、操作数栈、动态链接、方法返回地址这四大部分。
1.局部变量表,它的官方解释如下:
局部变量表 主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
基本数据类型肯定是可以理解的,这个 reference 类型其实就指向对象的一个指针,也就是对象的首地址,但还有一种就是去通过句柄去访问对象,相当于一个二级指针的意思,详情见下面两张图片:
2.操作数栈是作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中,很好理解。
3.动态链接,还是先看一下官方解释:
主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为动态连接。
Emm 单纯去调用一个对象的方法,就是直接去找方法区里面对应类的方法地址,但方法(1)去调方法(2),(2)在内存中其实是一个地址,也就是指针,指向了方法(2)的首地址。其中肯定要涉及到转化为真实物理地址,所以这就叫动态链接 🔗。
4.方法返回地址,这个很好理解了,汇编中就是 IP 指针指向下一条汇编指令的地址,而在 Java 中有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说,栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
这一块一定要着重记住的是,创建一个对象,不仅会在堆中去分配内存,还会在栈上去分配内存。
堆
1.实例&静态变量
new 一个对象会在堆去分配内存,这个是众所周之的,但是在 JDK7 以前,堆是分了 3 个区域,根据存活的时间被分为:年轻代(Young Generation)、年老代(Old Generation)、永久代(Permanent Generation,也就是之前的方法区)。
这里不去细说 Young Generation 和 Old Generation 了,实例创建好默认就在这两个区域,然后直接看这个永久代(之前的实现方法区的办法),永久代里面包含了类信息、字段信息、方法、常量、静态变量、即时编译器编译后的代码缓存等数据。但是 JDK7 开始字符串字面量和类的静态变量首先被从永久代被移出到 Java 堆中,目的是为了避免因为字符串字面量大量存储到字符串常量池中而导致的永久代内存溢出。然后 JDK8 中,JVM 彻底移除了永久代,同时引入元空间(Metaspace)来管理原来的元数据,这些元数据被分配到本地内存中进行管理。元空间默认上限是本地内存大小,所以降低了元空间 OOM 的可能性。而元空间是在主机内存中,至此元空间成为了实现方法区的办法。
上面一直写的是:实现方法区的办法,所以说方法区只是一个抽象的概念,怎么去构建这样一个方法区还是得看永久代(之前的办法)或者元空间(如今的办法)。但是静态变量永久的留在了堆中!
如果还是对静态变量永久的留在了堆中这点不理解,推荐去看一下这个 Q&R–>Stack overflow 上的答案
摘录一下这段话:
Static methods (in fact all methods) as well as static variables are stored in the PermGen section of the heap, since they are part of the reflection data (class related data, not instance related). As of Java 8 PermGen has been replaced by MetaSpace and as per JEP 122 it only holds meta-data while static fields are stored in the heap.
2.字符串常量池
这个和方法区中的常量池完全不是一回事,字符串常量池是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。本来这个字符串常量池也是在方法区的常量池里面的,但是 JDK8 之后由于上面说的原因,它也与方法区分离了,但确实还是在堆中。现在还有不少人以为,字符串常量还在方法区的常量池里面,堆中的字符串常量池只是一个指向字符串的指针。这种思想是错误的。
详情 🔎 见下图:
翻译:String 类的 intern()方法:一个初始为空的字符串池,它由类 String 独自维护。当调用 intern 方法时,如果池已经包含一个等于此 String 对象的字符串(用 equals(oject)方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并返回此 String 对象(注意是常量池中的对象,不是堆中的对象)的引用。 对于任意两个字符串 s 和 t,当且仅当 s.equals(t)为 true 时,s.intern() == t.intern()才为 true。所有字面值字符串和字符串赋值常量表达式都使用 intern 方法进行操作。
这里举个例子就清晰,如下:
String s1 = "abc";
String s2 = "abc";
System.out.println(s1==s2);
输出结果为True
上述代码创建了 1 个对象。采用字面值的方式创建一个字符串时,JVM 首先会去字符串池中查找是否存在abc
这个对象,如果不存在,则在字符串池中创建abc
这个对象,然后将池中abc
这个对象的引用地址返回给abc
对象的引用 s1,这样 s1 会指向池中abc
这个字符串对象;如果存在,则不创建任何对象,直接将池中abc
这个对象的地址返回,赋给引用 s2。因为 s1、s2 都是指向同一个字符串池中的abc
对象,所以结果为 true。
String s1 = new String("xyz");
String s2 = new String("xyz");
System.out.println(s1==s2);
输出结果为Flase
而上述代码创建了 3 个对象。采用 new 关键字新建一个字符串对象时,JVM 首先在字符串池中查找有没有xyz
这个字符串对象,如果有,则不在池中再去创建xyz
这个对象了,直接在堆中创建一个xyz
字符串对象,然后将堆中的这个xyz
对象的地址返回赋给引用 s1,这样,s1 就指向了堆中创建的这个xyz
字符串对象;如果没有,则首先在字符串池中创建一个xyz
字符串对象,然后再在堆中创建一个xyz
字符串对象,然后将堆中这个xyz
字符串对象的地址返回赋给 s1 引用,这样,s1 指向了堆中创建的这个xyz
字符串对象。s2 则指向了堆中创建的另一个xyz
字符串对象。s1 、s2 是两个指向不同对象的引用,结果当然是 False。
所以 new 出来的字符串是在堆中的,但是字面值创建的字符串是在堆中的字符串常量池,这个还是有区别的。
3.句柄池已经在上面栈那一块记录了,这里说一下好处:reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中实例数据指针,而 reference 本身不需要修改。
方法区
方法区目前是由元空间去实现的,具体的历史原因也记录在堆那一块了,方法区分为静态常量池(类元信息(Klass))和运行时常量池。
静态常量池(类元信息(Klass)):即*.class 文件中的常量池,class 文件中的常量池不仅仅包含类、方法的信息,占用 class 文件绝大部分空间。
运行时常量池:则是 jvm 虚拟机在完成类装载后,将 class 文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。
代码示例
给一个很简单的代码示例,两部分代码,一个 Application 类,一个 Dog 类,里面有静态变量、变量、静态方法、方法等元素。
public class Apliction {
public static void main(String[] args) {
Dog dog = new Dog();
dog.name = "lizi";
Dog.age = 9;
System.out.println(Dog.age);
System.out.println(dog.sum(1,2));
Dog.eat();
}
}
public class Dog {
String name;
static int age;
public static void eat() {
System.out.println("eating");
}
public int sum(int a, int b) {
return a + b;
}
}
根据此简单去画了一个流程图,只是大概的流程,不包含每一步的细节,且只画了 eat 函数,剩下的 sum 函数无非多了个参数,一个道理,POP 栈和清理堆没有画
大概阐述一下:
-
首先运行程序,Application.java、Dog.java 就会变为 Application.class、Dog.class,然后加入方法区,此时还是静态常量池,当 jvm 虚拟机在完成类装载后,将 class 文件中的常量池载入到内存中,并保存在方法区中,现在就是运行时常量池中。
-
遇到 main 方法,创建一个栈帧,入虚拟机栈,然后开始运行 main 方法中的程序。
-
然后虚拟机遇到一条 new 指令,进行类加载检查,分配内存,初始化内存值(所以一开始为 NULL、0),设置对象头,执行 init 方法,这里想去详细了解,可以去这篇文章中javaguide 的 Wiki中的 HotSpot 虚拟机对象探秘一栏中进一步了解。
-
给属性赋值,相关 static 变量以及字符串变量在内存中的具体位置图片也给出了,然后就是调用方法了,方法不管是 static 还是非 static 都是在方法区中的,堆中是保留了指向这个方法区中方法的指针。
-
有传参的方法和 C 语言一个逻辑……接着就是 POP 栈,Clear 堆……
-
上图中红字部分如果有不懂的,可以去看一下这篇文章 再深一步了解 Java 类的内存细节,不对应该是这个系列的文章(里面去详细讲述类的每一部分的加载先后顺序和在内存中的样子),因为里面涉及到 Java 的三大特性等等具体的细节,这里只是浅谈,不做过于深入的解释。
📝 记录一下吧,以上均是参考 JDK7、JDK 8 文档和前辈的文章,不同的 JVM 在加载 class 的处理方式都是不一样的,也曾做过几个实验,在调试过程中去监视动态变化的内存分配情况,但实验结果是不稳定的,没有参考价值,其实很简单就是 IDEA+jvisualvm 动态调试,通过修改不同的代码去对比内存中的变化,可参考IDEA 如何配置 jvisualvm这篇文章。还是理性看待 Java 的内存分配机制即可,无特殊需求不要钻牛角尖……