JVM
内存区域划分
JVM 的内存区域主要有四个区:
- 程序计数器
- 栈
- 堆
- 方法区
JVM 运行时数据区域也叫内存布局,它和 Java 内存模型(Java Memory Model,简称JMM)完全不同,属于完全不同的两个概念。但是不同的厂商,JVM 的具体实现是不一样的。
程序计数器
程序计数器占的区域在内存中是最小的一块。
- 作用是保存下一条指令的地址在哪里。
- 因为操作系统是以线程为单位调度执行的,每个线程都得记录自己的执行位置,所以每个线程都会有一个程序计数器。
- 指令就是字节码(就编译产生的字节码文件【后缀 .class】),程序要想运行,JVM 就得把 字节码文件 加载起来,放到内存中。然后程序就会把一条条指令,从内存中取出来,放到 CPU 上执行。
栈
栈里面放的是 局部变量 和 方法调用信息。
- 方法调用的时候,每次调用一个新的方法,就都涉及到 “入栈” 操作。
- 每次执行完一个方法,都涉及到 “出栈” 操作。
- 如果不停的调用方法,就会栈溢出。
- 栈里面的每一个元数,叫做栈帧。每个线程都有一份栈。
- 栈的空间是很小的,一般也就 几M 或 几十M,在递归的时候,没写好结束条件的话,很容易溢出的。
有关栈的内容,和栈帧的内容,在 C语言 里面讲过:传送门:深入了解函数栈帧
堆
每个进程只有一份堆,多个线程共用一个堆,new 出来的对象,就在堆上。
- 对象的成员变量也在堆中。
- 局部变量在栈上,成员变量和 new 的对象在堆上,引用也在堆上。引用如果在局部变量里面的话,就是在栈上
方法区
方法区里面放的是 “类对象” ,Java->class(二进制字节码)
- .class 会被加载到内存中。也就被 JVM 构成了类对象,这样的类对象就放到了方法区。
- 类对象就描述了 这个类 “长什么样”,描述类名字是啥,里面有哪些成员,有哪些方法,每个成员的名字和类型等等。
- 方法区里面还放着 static 修饰的成员(类属性),普通的成员就是“实例属性”
类加载
类加载主要就是把 .class 文件加载到内存中,构建成类对象。主要有三个部分:loading、linking、initializing。从 Java SE 官方文档可以看到
找到对应的 JDK:
点击下面的 HTML,进入:
然后向下方就能找到讲解类加载的部分:
这里的 Loading 就是讲解 类加载的 :
Loading
- 就是先找到对应的 .class 文件,然后打开并读取 .class 文件,同时初步生成一个 类对象。
- loading 中的一个关键环节 .class 文件会把读取并解析到的信息,初步填到类对象里面。
- .class 文件是一个二进制文件,但是里面也有相关的规则,找到第四部分 The class File Format(类的文件格式)
- 打开之后如下:
u4 就是 4个字节 的 unsigned int。u2 就是 2个字节 的 unsigned int。cp_info/field_info 都是结构体。 - 最上面的:
表示文件开头的四个字节是一个数字,主要表示文件的格式,因为二进制文件的种类有很多,Word,Excel,图片,视频,音频。。。。。而这些不同种类的二进制格式也不一样,所以二进制文件开头都有一个 magic number 来表示当前是一种什么样子的二进制文件。
Linking
- 这里一般是建立好多个实体之间的联系。
- 里面的 Verification 是校验的过程,主要是验证读到的内容是不是和 规范中《Java虚拟机规范》 规定的格式完全匹配,如果不规范就会类加载失败,并且抛出异常。验证选项有:文件格式验证,字节码验证,符号引用验证…
- Preparation:给静态变量分配内存,并且设置类变量的初始值。
- Resolution:根据编号找到对应的内容,并且填充到类对象中。.class 文件中,常量是集中放置的,每个常量有一个编号。.class 文件的 结构体 里初始情况下只是记录了编号。需要根据编号找到对应的内容,然后填充到类对象当中。
Initializing
真正对类对象进行初始化,尤其是针对静态成员。
双亲委派模型
经典面试题
先看代码:
class A {
public A() {
System.out.println("A 的构造方法");
}
{
System.out.println("A 的构造代码块");
}
static {
System.out.println("A 的静态代码块");
}
}
class B extends A{
public B() {
System.out.println("B 的构造方法");
}
{
System.out.println("B 的构造代码块");
}
static {
System.out.println("B 的静态代码块");
}
}
public class Test extends B{
public static void main(String[] args) {
new Test();
new Test();
}
}
求代码的运行结果。运行结果如下:
- 因为程序是从 main 开始执行,main 这里是 Test 方法,所以要执行 main,就需要先加载 Test。
- Test 继承自 B,要加载 Test,就要先加载 B
- B 继承自 A,要加载 B,就要先加载 A
- 只要这个类被用到了,就要先加载这个类(实例化,调用方法,调用静态方法,被继承…都算被用到)
这些执行的大的原则是:
- 类加载阶段会进行 静态代码块 的执行,想要创建实例,势必要先进行类加载。
- 静态代码块只是在类加载阶段执行一次。
- 构造方法和构造代码块,每次实例化都会执行,构造代码块在构造方法前面。
双亲委派模型
双亲委派模型是 类加载 中的一个环节,描述的是 JVM 中的 类加载器,如何根据类的全限定名(java.lang.String)找到 .class 文件的过程。默认的类加载器如下:
- BootStrapClassLoader:负责加载标准库中的类(String,ArrayList,Random,Scanner)。
- ExtensionClassLoader:负责加载 JDK 扩展的类(现在很少用)。
- ApplicationClassLoader:负责加载当前项目目录中的类。
程序员也可以自定义类加载器,来加载其他目录中的类,像 Tomcat 就自定义了类加载器,加载 webapps。
双亲委派模型,就描述了找目录过程中,也就是上面那三个类加载器是如何配合的:
-
加载标准库,假设是加载 java.lang.String :
a)程序启动,先进入 ApplicationClassLoader 类加载器。
b)ApplicationClassLoader 会检查它的父加载器是否已经加载过了,如果没有,就调用父加载器 ExtensionClassLoader。
c)ExtensionClassLoader 也会检查下,他的父加载器是否加载过了,如果没有,就调用父加载器 BootStrapClassLoader。
d)BootStrapClassLoader 也会检查它的父加载器是否加载,自己没有父亲,于是自己扫描自己负责的目录。
e)java.lang.String 这个类在标准库中能找到,直接由 BootStrapClassLoader 负责后续的加载过程,查找环节就结束了。
f)大致流程如下:
-
加载自己的类:
a)程序启动,先进入 ApplicationClassLoader 类加载器。
b)ApplicationClassLoader 会检查它的父加载器是否已经加载过了,如果没有,就调用父加载器 ExtensionClassLoader。
c)ExtensionClassLoader 也会检查下,他的父加载器是否加载过了,如果没有,就调用父加载器 BootStrapClassLoader。
d)BootStrapClassLoader 也会检查它的父加载器是否加载,自己没有父亲,于是自己扫描自己负责的目录,没扫描到,于是回到子加载器继续扫描。
e)ExtensionClassLoader 也扫描自己负责的目录,也没扫描到,回到子加载器继续扫描。
f)ApplicationClassLoader 也扫描自己负责的目录,能找到 Test 类,于是进行后续加载,查找目录的环节结束。
g)最终 ApplicationClassLoader 也找不到,就会抛出 ClassNotFoundException 异常。
f)流程如下:
上面这一套的查找规则,就称为 “双亲委派模型” 。更直观的就是我们有问题问班长,班长再问辅导员,然后辅导员告诉班长,班长再告诉我们。
JVM 这样设计的原因是:一旦程序员自己写的类,和标准库中的类,全限定类目重复了,也能够顺利加载到标准库当中的类。如果是自定义类加载器,不一定遵守双亲委派模型。
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("和限定类重复");
}
}
运行结果如下:
垃圾回收
在写代码的时候,经常回申请内存,当不需要的时候,就需要回收内存。于是就有了内存的释放时机,早了不行,迟了也不行。
- 如果释放内存早了,变量还需要被使用,结果内存以及被回收了,变量就无法使用了。就导致出 bug 了。
- 如果释放迟了,那就是浪费内存了。就像图书馆占座。人不在,但是占座浪费时间。
垃圾回收,本质上事考运行时环境,额外做了很多的工作,来自动释放内存。
垃圾回收的缺点:
- 额外的资源消耗。
- 可能回影响程序的流畅运行。(垃圾回收经常会引入 STW 问题,就是 Stop The World,时间停止)
垃圾回收的是什么
内存是由很多种的:
- 程序计数器:固定大小,不涉及到释放,也就不需要 GC。
- 栈:函数执行完毕,对应的栈帧就自动释放了,也不需要 GC。
- 堆:这里是最需要 GC 的,代码中大量的 “内存” 都是在堆上。
- 方法区:类对象,类加载来的,进行 “类卸载” 的时候,就需要释放内存,卸载操作时一个非常非常低效的操作。
堆中的内存布局是这样的:
像上面这种,一部分在使用,一部分不再使用,是不释放的。等对象完全不使用的时候,才真正释放。GC 会提高软件的开发效率。
垃圾回收的两个大阶段:
- 找垃圾,判定垃圾。
- 释放垃圾。
找垃圾/判定垃圾
基于引用计数
就是针对每个对象,都会额外引入一小块内存,保存这个对象有多少个引用指向它。假如说,这样的代码 Test t = new Test();
,他的引用计数就是这样的:
如果再加一个 Test t2 = t;
就是 t 和 t2 都是指向这个对象的引用,那么结果如下:
内存不在使用的时候,就该释放了。也就是引用计数为 0 的时候,就不再使用了。
基于引用计数的致命缺陷:
-
空间利用率低,每个 new 的对象都需要计数器,如果对象本身很小(比如说只有四个字节),那么多出的计数器,相当于空间浪费了一倍。
-
会有循环引用的问题。如下面的代码
class Test { Test t = null; } Test t1 = new Test(); Test t2 = new Test();
内存布局如下:
如果加入这样的代码:t1.t = t2; t2.t = t1;
意思就是 t2 赋给了 t1 里面的 t 属性。t1 赋给了 t2 里面的 t 对象。内存模型如下:
然后接下来,把指向置为 null :t1 = null; t2 = null;
然后两个对象的引用技术,不为 0,所以无法释放,但是由于引用长在了彼此的身上,外界的代码又无法访问到这个两个对象。初始窗口,这俩对象就被孤立了,既不能使用,又不能释放,就出现了 “内存泄漏” ,如下图:
基于可达性分析
就是通过额外的线程,定期的针对整个内存空间的对象进行扫描。有一些起始位置(GCRoots),会类似于 深度优先遍历一样,把可以访问到的对象进行标记,能标记的就是可达对象,如果没标记就是不可达,就是垃圾。
-
非垃圾:
这些节点都能达到,所以就不是垃圾。 -
垃圾,也就是如果
a.right = null
右边的访问不到,所以就是垃圾。 -
GCRoots:
a)栈上的局部变量
b)常量池中的引用指向的对象
c)方法区中的家庭成员指向的对象 -
可达性分析的优点,克服了 引用计数的两个缺点,自身的缺点就是系统开销大,遍历一次可能比较慢。
-
找垃圾的核心就是:没有引用指向,就不使用了,就是垃圾。
回收垃圾
主要有三种策略:
- 标记-清除
- 复制算法
- 标记-整理
标记-清除
标记就是可达性标记,清除就是直接释放内存,释放之后的内存可能是不连续的,就是内存碎片:
-
释放之前:
-
释放之后:
于是就有了分散开的内存碎片。 -
假设空闲内存是 1G,可能是多个空闲内存加起来一共有 1G,如果要申请 500M 的内存,也可能申请失败。如下图:
这样的问题,会影响程序的执行。
复制算法
为了解决内存碎片,引入的复制算法。就是把申请的内存一分为二,然后不是垃圾的,拷贝到内存的另一边,然后把原来的一半内存空间整体都释放。
-
内存使用情况:
-
然后就是把不是垃圾的拷贝到另外一半,然后释放原来的另一半:
-
释放结果:
这样的话,内存碎片问题就解决了。 -
复制算法的问题:
a)内存空间利用率低。
b)如果保留的对象多,要释放的对象少,此时复制开销就很大。
标记-整理
就是针对复制算法,再做出改进。类似于顺序表删除中间元素,有一个搬运操作。
-
内存情况:
-
将未标记的元素,都拷贝(移动)到前面:
-
释放内存:
-
但这种方法还是有问题:不能解决复制/搬运的开销问题。
实际在使用的时候,是多种方法相结合起来的,也就是 “分代回收”
分代回收
就是针对进行分类(根据对象的 “年龄” 分类),一个对象熬过一轮 GC 的扫描,就 “长了一岁”。针对不同年龄的对象,采取不同的方案。 一块内存,分为两半,一半放 “新生代” ,一半放 “老年代” ,然后新生代里面又有伊甸区和两个 “幸存区” 。幸存区大小一样:
- 对象刚创建出来的时候,就放在伊甸区。
- 如果伊甸区的对象熬过一轮GC扫描,就会被拷贝到 幸存区(应用了复制算法)。
- 在后续的几轮 GC 中,幸存区的对象在两个幸存区之间来回拷贝(复制算法),每一轮都会淘汰掉一波幸存者。
- 在持续若干次之后,对象就进入老年代,一个对象越老,继续存活的可能性就越大,所以老年代的扫描频率大大低于新生代,老年代中使用标记整理的方式进行回收。
分代回收中,还有一个特殊情况: 有一类对象可以直接进入老年代(大对象,占有内存多的对象),大对象拷贝开销比较大,不适合使用复制算法,所以直接进入老年代。
垃圾回收器的实现
常用的垃圾回收器如下:
- Serial 收集器(新生代)/ Serial Old 收集器(老年代):这俩都是串行收集,在进行垃圾扫描和释放的时候,业务线程要停止工作。就是这边停止工作,他先扫描完,再去进行释放,然后业务线再继续工作。这种方式扫描的慢,释放的也慢,也会产生严重的 STW 问题。
- ParNew 收集器(新生代)/Parallel Scavenge 收集器(新生代,是并行清除)/ Parallel Old 收集器(老年代):这些回收器都是并发收集的,引入了多线程,Parallel Scavenge 比 ParNew 多出了一些参数,可以用来控制 STW 的时间。
下面是新的垃圾回收器,核心思想就是:化整为零,:
- GMS 收集器:设计的比较巧妙,设计初衷是为了尽可能的让 STW 时间短。
a)初始标记,速度很快,会引起短暂的 STW(只是找到 GCRoots)
b)并发标记,虽然速度很慢,但是可以和业务线程并发执行,不会产生 STW
c)重新标记,在 b 的业务代码可能会影响并发标记的结果,针对 b 的结果进行微调,虽然会引起 STW,但是只是微调,速度快。
d)回收内存,也是和业务线程并发的,所以就没有 STW。 - G1 收集器:是唯一一款全区域的垃圾回收器。
a)把整个内存,分成了很多小的区域。
b)给这些区域进行了不同的标记。
c)有的区域放新生代对象,有的放老年代对象。
d)然后再扫描的时候,一次扫描若干个区域(不追求一轮 GC 就扫描完,分舵从来扫)对于业务代码影响是更小的。
e)在当下可以优化带让 STW 停顿时间小于 1ms。