JVM 其实本质上就是一个 Java 进程,JVM 启动之后就会从操作系统申请到一大块内存,在程序启动时,JVM 这个 java 进程就会对它申请到的这块内存空间划分多个区域,每个区域都有自己的功能。
JVM 内存区域划分
1. 堆
堆中存放的时程序
new
出来的对象
2. 方法区
方法区中存放的是
类对象
一个 .java
程序启动时,就会生成一个 .class
文件,JVM 会将这个 .class 进行加载,加载到内存中 → 就变成了类对象
- 类对象里都有什么?
- 包含了这个类的各种属性的名字,类型,访问权限
- 包含了这个类的各种方法的名字,参数类型,返回值类型,访问权限,以及方法的实现的二进制代码
- 包含了这个类的
static
成员
程序计数器
程序计数器是内存中最小的一个部分,它里面存放的只是一个
内存地址
,就是程序接下来要执行的指令地址。
栈
栈中存放程序中的
局部变量
。
成员变量 & 局部变量 & 静态成员变量
虚拟机栈和本地方法栈的区别
本地方法栈: 指的是给 JVM 内部的方法(使用 C++ 实现的方法)去使用的
虚拟机栈:给上层的 Java 代码来使用的。
在 JDK1.8 之后这两块区域已经没有了本质区分
Java 中的类加载
类加载其实是 JVM 中的一个非常核心的流程,他所做的事情就是把
.class
文件,转换成 JVM 中的类对象。
当源代码编译成可执行程序后再加载成类对象之后,才能开始执行。
对于类加载来说总共分为以下几个步骤:
- 加载
- 连接
- 验证
- 准备
- 解析
- 初始化
类加载中的“双亲委派模型”
进行类加载,其中一个非常重要的环节,就是根据这个类的名字:例如“java.lang.String”,找到对应的 .class 文件,在 JVM 中,有三个类加载器(三个特殊的对象)来负责进行这里的找文件操作。
背景:
源码
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先,检查请求类是否被加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出 ClassNotFoundException,说明父类加载器无法完成加载请求
// from the non-null parent class loader
}
if (c == null) {
// 在父类加载器无法加载时,再调用本身的 findClass 方法进行类加载
long t1 = System.nanoTime();
c = findClass(name);
// 记录统级信息
...
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
当一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,
而是把这个请求委派给父类加载器去完成
,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载
。
垃圾回收(GC)
我们首先要知道,垃圾回收,回收的是 “内存”, 前面讲到说 JVM 其实就是一个 Java 进程,一个进程是会持有很多的硬件资源的(CPU,内存,硬盘,带宽资源…),但是系统的内存总量是一定的,程序在使用内存的时候,必须先申请才能使用,内存要同时给很多个进程来使用,所以当前进程在使用完之后,就需要进行释放。
对于 Java 来说,代码中的任何地方都可以申请内存,然后由 JVM 统一进行释放,具体来说,就是在 JVM 内部,专门持有一组负责垃圾回收的线程来进行这样的工作。
垃圾回收要回收的内存是哪些?
- 日常讨论的 “垃圾回收” 主要就是指 “堆上内存的回收”;因为在 Java堆中存放着几乎所有的对象实例,
程序员从代码编写的角度来看,内存的申请时机,是非常明确的。但是内存的释放时机,很多时候不太明确
。- 而 栈上的内存是和“具体的线程” 绑定在一起的,
会随着线程启动创建,线程代码块执行结束,内存就自动释放了
。
回收堆上的内存,具体是回收什么?
在堆上,主要的内存使用就是 new 出了很多对象。
此时针对堆上的对象,也分为三种:
- 完全要使用
- 完全不适用
- 一半要使用,一半不使用
GC 中回收内存,其实就是回收这些
完全不使用
的对象。
垃圾回收基本思想
- 先找出要回收的垃圾对象
- 再去回收垃圾对象的内存空间
死亡对象判断算法
1. 引用计数算法
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就 +1;当引用失效时,计数器就 -1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。
引用计数的优点:
规则简单,实现方便,比较高效(程序运行效率高)
引用计数的缺点:
空间利用率比较低
如果一个对象很大,在程序中出现的对象数目也不多,此时给对象增加四个字节的引用计数位没什么影响。
如果一个对象很小,在程序中对象的数据也很多,此时引用计数就会带来不可忽视的空间开销。存在循环引用的问题(致命伤)
有些特殊的代码下,循环引用会导致代码的引用计数判断出现问题,从而无法回收。
所以我们在 Java 中并没有使用引用计数这种方式来判定对象是否“死亡”,而是用了以下的方式:
2. 可达性分析算法
此算法的核心思想为 : 从一组初始的位置(GC Roots)出发,向下进行深度遍历,把所有能够访问到的对象都标记成“可达”(可以被访问到),对应的,不可达对象(没有标记的对象),就是“死亡”对象。
在Java语言中,可作为GC Roots的对象包含下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
比如:各个线程被调用的方法中使用到的参数、局部变量等。 - 方法区中类静态属性引用的对象;
比如:Java类的引用类型静态变量 - 方法区中常量引用的对象;
比如:字符串常量池(string Table)里的引用 - 本地方法栈中 JNI(Native方法)引用的对象。
不管是引用计数,还是可达性分析,判定原则其实都是看当前这个对象是否有引用来指向,是通过引用来决定 “对象的生死”。
垃圾回收算法
1. 标记-清除算法
当堆中的有效内存空间被耗尽的时候,会停止整个程序(也称stop the world),然后进行两项工作, 第⼀项 是标记,第⼆项则是清除。
- 标记:Collector从引⽤根节点开始遍历,标记所有被引⽤的对象。⼀般是在对象的Header中记录为可达对象。
- 清除:Collector对堆内存进⾏从头到尾的线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收
标记清除算法的问题:
被标记的空间并不是一整块连续的空间,而是和存活对象穿插着的一小块一小块的碎片空间。如果我们将标记的空间进行删除,再次使用申请空间时,很多时候我们是需要申请一块连续存储的内存空间的,虽然内存空间足够,但是由于内存碎片,空间不连续,也会导致内存分配失败。
2. 复制算法
"复制"算法是为了解决"标记-清理"的效率问题。 它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。
这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。
复制算法的问题:
- 可用的内存空间只有一半
- 如果要回收的对象比较少(剩下的对象比较多),复制的开销就会非常大
3. 标记整理算法
复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。
针对老年代的特点,提出了一种称之为"标记-整理算法"。
标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存
。
这样的操作,就可以有效避免内存碎片,同时也能提高空间利用率。
标记整理算法的问题:
在这个搬运的过程中,也会是一个很大的开销,这个开销要比复制算法里面的复制对象的开销甚至更大。
4. 分代算法
分代算法和上面讲的 3 种算法不同,分代算法是通过区域划分,实现不同区域和不同的垃圾回收策略,从而实现更好的垃圾回收。
当前 JVM 垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法
。
如何进行分代?
哪些对象会进入新生代?哪些对象会进入老年代?
在 JVM 中,进行垃圾回收扫描(可达性分析)也是周期性的。 这个对象每次经历了一个扫描周期,就认为 “长了一岁”。
就根据这个对象的年龄,来对整个内存进行了分类,把年龄短的对象放一起,年龄长的放一起。
不同年龄的对象,就可以采取不同的垃圾回收算法来进行处理了。
特殊情况:如果这个对象特别大,会直接进入老年代
因为:如果这个大对象直接放在新生代,来回拷贝的开销会非常大,就会直接将这样的大对象放在老年代。
总结:一个对象的一生
一个对象的一生:我是一个普通的 Java 对象,我出生在 Eden 区,在 Eden 区我还看到和我长的很像的小兄弟,我们在 Eden
区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了 Survivor 区的 “From” 区(S0 区),自从去了
Survivor 区,我就开始漂了,有时候在 Survivor 的 “From” 区,有时候在 Survivor 的 “To” 区(S1
区),居无定所。直到我 18
岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在老年代里,我生活了很多年(每次GC加一岁)然后被回收了。