什么是垃圾?
当一个对象,没有任何引用指向它,这个对象就可以被称作垃圾。
JAVA和C++内存管理的对比
怎么定位垃圾?
定位垃圾的算法有两种:
1、引用计数算法(ReferenceCount)
也就是对象身上有个计数器,记录着有几个对象引用着它。
当计数器为0的时候,这个对象就被视为一个垃圾了。
不能解决的问题
循环引用
引用计数,不能解决这种,一堆垃圾,没有外部指向它们,但它们互相引用着各自,导致计数永远不为0,这一团垃圾就永远不能被回收。
2、根可达算法(RootSearching)
就是说,从一堆“根”对象,从它们为起源,开始向着它们自身内部所引用的对象依次向下去搜寻下去,如果哪个对象不能顺着这条线被连接上,那么这个对象就将被视为垃圾。
那什么样的对象是“根”对象呢?
1、main方法栈帧内的所有变量。(也就是JVM栈)
2、方法区里存放的静态变量
3、运行时常量池里的东西
4、native方法栈帧里的变量(也就是本地方法栈)
5、Class对象
通过这种算法,“垃圾块”的问题就可以被解决了。
GC回收算法
找到了垃圾,那具体我要使用什么方式来回收这些垃圾呢?
一般来讲,GC回收算法有三种:
1、Mark-Sweep(标记清除)
算法的思想是,一次性先标记可以被回收的垃圾对象,然后再对这些被标记的对象进行清理。
这个过程大致是这样的:
通过GC Roots(根对象们),向下一直找,能连上的就不是垃圾,标记为不可回收。
然后再对那些GC Roots这根线连不上的那些对象,进行标记为可回收垃圾对象,进行回收。
适用场景
算法比较简单,适合存活对象比较多的情况下,效率会比较高。(可想而知,eden区的回收对象每次都比较多,就不太适合这种)
弊端
它需要进行两遍扫描,效率是偏低的。
并且比较容易产生碎片。
2、Copying(拷贝)
将内存一分为二。实际可用内存只有一半。
在垃圾回收的时候,只需要找到那些不是垃圾的存活对象,一次性将它们都拷贝到另外一块内存中,当前这块内存全部清理掉。
适用场景
它只扫描一次,效率比标记清除有所提高,且不会产生内存碎片。
适用于存活对象较少的情况(eden区的对象垃圾回收就比较合适)
弊端
内存一分为二,只有一半可用,会造成空间的浪费。
并且每次垃圾回收都会全量拷贝对象到另外一块内存,涉及到移动复制对象,需要调整对象的引用。
3、Mark-Compact(标记压缩)
和标记清除有点像,但不同的是,它会在对标记为垃圾的对象清理之后,有一个将剩余存活对象进行整理向前移动汇总的动作,这就避免了碎片化的产生。
优点
不会产生碎片,方便对象分配内存
不会产生内存减半的问题
弊端
它需要像标记清除一样,进行扫描两次。
并且额外的整理的动作,还会涉及到对象的移动。
因此这种算法效率是偏低的。
JVM内存分代模型
部分垃圾回收器使用的模型
除Epsilon 、ZGC、 Shenandoah之外的GC垃圾回收器都是使用逻辑分代模型
G1是逻辑分代,物理不分代。
除此之外不仅逻辑分代,而且物理分代。
堆内存逻辑分区
网上大多数说法都是新老年代的比例是1:3
不过经过笔者测试,发现在jdk1.8中,新老年代区的比例是1:2
堆内存分为主要分为两块区域:
1、新生代区
存放新生对象。
新生代这块区域,又细分了几块区域:
1块eden区,2块survivor区,它们的内存占比是8:1:1
eden区满了触发YGC(对新生代区域进行清理)。
采用的垃圾回收算法一般都是复制算法
2、老年代区
存放顽固老不死对象。
新生代和老年代的内存占比是1:3
old区满了触发FGC(同时对新生代和老年代进行清理)
采用的垃圾回收算法可能是标记整理,也可能是标记清除(根据垃圾回收器的实现而定)
NON HEAP-方法区(永久代/元空间)
除了上面的堆内分布之外,还有一块区域,叫做方法区,里面存储的就是Class,常量等的信息,方法区是一种规范。
JVM规范有意将这块区域划分为非堆空间,但在jdk1.8以前这是逻辑意义上的,实际的存储位置还是在堆上。在jdk1.8以前,方法区的实现就是永久代,永久代依旧是堆上分配的一块空间,但几乎不会被GC回收(或者说很苛刻)。
从jdk1.8开始,这块空间正式从堆中被分离出来,方法区的实现是元空间。
永久代和元空间的区别
永久代必须指定大小限制 。(还是占用的堆空间)
元数据可以设置,也可以不设置,无上限(受限于物理内存)
方法区和字符串
.在JDK1.7以前,字符串常量是存放在方法区中的。
在JDK 1.7后,字符串常量池被从方法区里剥离出来,在 Java 堆(Heap)中单独开辟了一块区域存放运行时字符串常量池。
一个对象的生命周期
一个对象被new了出来
首先尝试分配到栈内
栈内放不下了,将被分配到了eden区
在经历一次年轻代的垃圾回收后,eden区会将剩余存活的对象放入某块survivor区(这里假设是s1),
在下一次年轻代的垃圾回收后,会将eden区存活的对象和S2中存活的对象一并复制到s1中,并清除掉eden区和s2
重复这样的过程:
将eden区存活的对象和S1中存活的对象一并复制到s2中
将eden区存活的对象和S2中存活的对象一并复制到s1中
当到达一个阈值时(升级年龄),如果某些对象还活着,那这些对象将被放入到老年代中。
总结过程:
1、YGC回收之后,大多数的eden区对象会被回收,活着的进入s0
2、再次YGC,活着的对象eden + s0 -> s1
3、再次YGC,eden + s1 -> s0
4、年龄足够 -> 老年代 (15 CMS 6)
5、s区装不下 -> 老年代
GC概念
MinorGC = YGC
MajorGC = FGC
CG何时触发
1、YGC
当eden区空间不足的时候,就会触发YGC。
2、FGC
当old区空间不足时,会触发FGC。除此之外,还可以通过手动调用System.gc来通知虚拟机触发垃圾回收(但这个调用并不一定保证虚拟机会立马进行回收)
细节探究
栈上分配
如果你学过c或者c++,一定知道有个东西叫struct结构体的东西,是可以直接在栈上分配的。
而java也实现了类似的机制,一个对象在被new出来之后,首先会尝试向栈上进行分配,但栈上分配存在前提:
1、线程私有小对象
它必须是线程私有的,且比较小的,能够让栈放的下的对象。
2、无逃逸
什么叫逃逸? 就是说new出来的对象出了它所在的代码块,就没有人再能够找到它了,这样的就是无逃逸的。
void alloc(int i) {
//只在当前代码块可以找到
new User(i, "name " + i);
}
但如果你在代码块里操作将这个对象的引用赋予给了某个成员属性,那就产生了逃逸。
void alloc(int i) {
//赋予给成员变量,发生了逃逸
this.user = new User(i, "name " + i);
}
支持标量替换
此外,还支持标量替换。形如这种:
void test(int i) {
int a = this.a;
int b = this.b;
}
所谓标量替换就是说,JVM可以优化,让两个int值代替对象的存在,而不必分配对象空间。
线程本地分配(Thread Local Allocation Buffer)
每个线程会默认占用eden区1%独属于自己线程的空间,这样多线程的时候会首先向这块空间去分配,而无需竞争eden区就可以申请空间,提高效率。
当然,前提得是一个小对象,如果是比较大的话,这块空间也是无法放的下,只能去竞争了。
测试程序
public class TestTLAB {
//User u;
class User {
int id;
String name;
public User(int id, String name) {
this.id = id;
this.name = name;
}
}
void alloc(int i) {
new User(i, "name " + i);
}
public static void main(String[] args) {
TestTLAB t = new TestTLAB();
long start = System.currentTimeMillis();
for(int i=0; i<1000_0000; i++) t.alloc(i);
long end = System.currentTimeMillis();
System.out.println(end - start);
}
}
默认JVM是开启了逃逸分析,标量替换,线程本地分配的。
我们直接来运行下程序,看一下执行时间:582
然后再通过JVM参数指定:
-XX:-DoEscapeAnalysis //取消逃逸分析
-XX:-EliminateAllocations //取消标量替换
-XX:-UseTLAB //取消使用线程本地分配
再看一下执行时间:1050
可以发现,差距还是不小的。
对象何时进入老年代
通过分代年龄
一般来说,升代年龄是可以通过参数来指定。
默认的话,不同垃圾回收器的年龄各有不同。
但我们之前分析过markword,知道是用4位来存储分代年龄的,因此可以断定,这里最大也只能是15。
动态年龄
当进行垃圾回收后,eden区+某个s区的存活对象一次性放到另外一个s区的时候,s区的大小超过了50%的时候,此时会直接把年龄最大的放入老年代。
参考文章:https://www.jianshu.com/p/989d3b06a49d
分配担保
YGC期间 survivor区空间不够了 空间担保直接进入老年代
参考:https://cloud.tencent.com/developer/article/1082730
对象分配过程图
一个对象被new出来,首先会尝试看能不能放入栈上,如果可以,只需要弹出就可以。
如果不能放,要看这个对象是不是大对象,如果是,则直接进入old区。
不是大对象,则优先看能否使用线程本地分配,无论能与否,都进入eden区。
在垃圾回收的时候,开始使用复制算法来回将eden区和某一s区的存活对象放入另外一个空的s区,反复这样的过程。
直到年龄判断或者动态年龄导致,会让对象进入old区,直到FGC才回收。
在old区满了的时候,会触发FGC,FGC会将新生代和老年代的区域都进行垃圾回收。