垃圾回收机制的意义
垃圾回收可以有效的防止内存泄露,有效的使用空闲的内存;
内存泄露:指该内存空间使用完毕后未回收,在不涉及复杂数据结构的一般情况下,java的内存泄露表现为一个内存对象的生命周期超出了程序需要它的时间长度,我们有是也将其称为“对象游离”;
整体了解 JDK & JVM
首先要对官方的 SDK 有点认识,同时要明白下面的概念:
- Java SE(Java Platform, Standard Edition):它是 Java 的标准版,主要用于桌面应用开发,同时也是 Java 的基础,它包含 Java 语言基础、JDBC(Java 数据库连接性)操作、I/O(输出输出)操作、网络通信、多线程等技术。
- Java EE(Java Platform, Enterprise Edition):它是 Java 的企业版本(javax..*),包含了 Servlet、JSP、JMS、JNDI 等的扩展。
- Java ME(Java Platform, Micro Edition):一般是指 Java ME Embedded,Java 微型版本,一般做嵌入式开发用。
- JRE(Java Runtime Environment),Java 运行环境。
- JDK(Java Development Kit),Java 开发者工具集,是用来编译和执行 Java 程序必备的 Java 开发环境,现在我们一般说 JDK 就是指的 Oracle 的 Java SE。因为 Sun JDK 和 Open JDK、JRockit 都被 Orcale 收购了,一统了江湖。
我们看下 Oracle 官方的图,如下:
这样我们对 JDK、JRE,有了概念上的认识之后,我们来看下我们的 JVM(Java Virtual Machine)——Java 虚拟机,也就是 HotSport 了。
Java 内存模型
Java 内存模型是理解 Java 多线程和 Java GC 必须要了解的抽象知识点,我们可以通过工具来更好的掌握 Java 内存模型。我给大家一个点:“我们通过不同的视角来理解内存模型”。我们可以通过不同的视角来理解内存模型。因为 JVM 类里面实际的内存操作远比我们想象得要复杂,因为这部分代码是 Oracle 官方的核心机密,没有对外公开,我们也只能通过官方文档及其 Jdk/bin 目录下面的工具来做到整体认识。
站在理解线程的视角看内存模型
我们可以把 JVM 内存结构直接分成线程私有内存和共享主内存。这样我们就可以很好地理解多线程的很多问题如同步锁、lock、validate 关键字,及其 ThreadLocal.
我们从内存设置的角度出发
我们可以将内存直接分位堆内存、非堆内存(JDK8 以后叫 Metaspace,元空间)和其它,三个大的类别。 我们来看一下 JConsole 和 JVisualVM。
java/bin/jconsole 打开以后界面如下:
java/bin/jvisualvm 打开以后界面如下:
利用 tools,也可以看到 Java 工具也是简单的将其分成堆和非堆(Metaspace)。
而其它是什么呢?
Other 指的是“直接内存”,如一些(IO/NIO),这些 JVM 控制不了(如果线程变多线程栈吃的内存也会变的非常大,不可设置)。
对应的 JVM 设置的参数是:
- Xmx4g:JVM 最大允许分配的堆内存,按需分配;
- Xms4g:JVM 初始分配的堆内存,一般和 Xmx 配置成一样以避免每次 gc 后 JVM 重新分配内存;
- XX:MetaspaceSize=64m 初始化元空间大小;
- XX:MaxMetaspaceSize=128m 最大化元空间大小。
Metaspace 建议大家不要设置,一般让 JVM 自己启动的时候动态扩容就好了,没必要自己去设置。如果不动态加载 class ,当启动起来的时候,一般是很少有变化的。
从这个角度我们可以认为我们的 JVM 内存的大小是堆+metaspace+io(运行时产生的大小)。
我们从 JVM 的运行期的视角来看
可以分为五大部分:方法区、堆、本地方法栈区、PC 计数器、线程栈。我们也可以看下面的图,PC 计数器和栈、本地方法栈,是随着当前的线程开始而开始,销毁而销毁的。
我们再通过下面这个图理解一下这五个区和线程的关系:
对应的 JVM 的参数为 Xss512k,用来设置每个线程的堆栈大小。
从垃圾回收机制的视角来看
垃圾回收算法种类
标记-清除算法
标记-清除算法分两个步骤,分别为“标记”和“清除”,字如其人。它是一个最基础的垃圾回收算法,更高级的垃圾回收算法都是基于它改进的。
它的运行过程是这样的:首先标记出所有需要回收的对象,标记完成后,再统一回收掉所有被标记的对象。
标记-清除算法的缺点有两个,一个是空间问题,标记清除之后会产生大量的不连续内存碎片。内存碎片太多,程序在之后的运行过程中就有可能找不到足够的连续内存来分配较大的对象,进而不得不提前触发另一次垃圾回收,导致程序效率降低。标记-清除算法的另一个缺点是效率问题,标记和清除的效率都不高,两次扫描耗时严重。
复制算法
复制算法把内存按容量划分为大小相等的两块,每次只使用其中的一块。如果正在用的这块没有足够的可使用空间了,那么就将还活着的对象复制到另一块去,再把使用过的内存一次性清掉。
这样就实现了简单高效的做法,每一次进行内存回收时,就不用再去考虑内存碎片这些复杂的情况,只需要移动堆顶指针就可以。但是缺点也很明显,可使用内存只有原来的一半了,而且持续复制生命力很旺盛的对象也会让效率降低哇。复制算法适用于存活对象少、垃圾对象多的情况,这种情况在新生代比较常见。
标记-压缩算法
在老年代,大部分对象都是存活的对象,复制算法在这里就不靠谱了,所以有人提出了标记压缩算法,标记过程和标记清除算法一样,但是清理时不是简单的清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存,需要移动对象的成本。
分代垃圾收集算法
之前说过,逐一标记和压缩 Java 虚拟机里的所有对象非常低效:分配的对象越多,垃圾回收需时就越久。不过,根据统计,大部分的对象,其实用没多久就不用了。
来看个例子吧。(下图中,竖轴代表已分配的字节,而横轴代表程序运行时间)
上图可见,存活(没被释放)的对象随运行时间越来越少。而图中左侧的那些峰值,也表明了大部分对象其实都挺短命的。
JVM 分代
根据之前的规律,就可以用来提升 JVM 的效率了。方法是,把堆分成几个部分(就是所谓的分代),分别是新生代、老年代,以及永生代。
新对象会被分配在新生代内存。一旦新生代内存满了,就会开始对死掉的对象,进行所谓的小型垃圾回收过程。一片新生代内存里,死掉的越多,回收过程就越快;至于那些还活着的对象,此时就会老化,并最终老到进入老年代内存。
Stop the World 事件 —— 小型垃圾回收属于一种叫 "Stop the World" 的事件。在这种事件发生时,所有的程序线程都要暂停,直到事件完成(比如这里就是完成了所有回收工作)为止。
老年代用来保存长时间存活的对象。通常,设置一个阈值,当达到该年龄时,年轻代对象会被移动到老年代。最终老年代也会被回收。这个事件成为 Major GC。
Major GC 也会触发STW(Stop the World)。通常,Major GC会慢很多,因为它涉及到所有存活对象。所以,对于响应性的应用程序,应该尽量避免Major GC。还要注意,Major GC的STW的时长受年老代垃圾回收器类型的影响。
永久代包含JVM用于描述应用程序中类和方法的元数据。永久代是由JVM在运行时根据应用程序使用的类来填充的。此外,Java SE类库和方法也存储在这里。
如果JVM发现某些类不再需要,并且其他类可能需要空间,则这些类可能会被回收。
分代垃圾收集过程
现在你已经理解了为什么堆被分成不同的代,现在是时候看看这些空间是如何相互作用的。 后面的图片将介绍JVM中的对象分配和老化过程。
首先,将任何新对象分配给 eden 空间。 两个 survivor 空间都是空的。
当 eden 空间填满时,会触发轻微的垃圾收集。
引用的对象被移动到第一个 survivor 空间。 清除 eden 空间时,将删除未引用的对象。
在下一次Minor GC中,Eden区也会做同样的操作。删除未被引用的对象,并将被引用的对象移动到Survivor区。然而,这里,他们被移动到了第二个Survivor区(S1)。此外,第一个Survivor区(S0)中,在上一次Minor GC幸存的对象,会增加年龄,并被移动到S1中。待所有幸存对象都被移动到S1后,S0和Eden区都会被清空。注意,Survivor区中有了不同年龄的对象。
在下一次Minor GC中,会重复同样的操作。不过,这一次Survivor区会交换。被引用的对象移动到S0,。幸存的对象增加年龄。Eden区和S1被清空。
此幻灯片演示了 promotion。 在较小的GC之后,当老化的物体达到一定的年龄阈值(在该示例中为8)时,它们从年轻一代晋升到老一代。
随着较小的GC持续发生,物体将继续被推广到老一代空间。
所以这几乎涵盖了年轻一代的整个过程。 最终,将主要对老一代进行GC,清理并最终压缩该空间。