引用计数和可达性分析
引用计数指的是向每个对象添加一个引用计数器, 一旦有此对象的引用,就加1, 反之, 就减1, 当此对象的计数器为0的时候, 就可以回收此对象了.
但是引用计数器有一个漏洞, 就是无法解决循环依赖, 例如一下的例子:
A a = new A();
B b = new B();
a.handle(b);
b.handle(a);
类似这样的例子,a引用b, b引用a. 但是当没有任何其他地方引用a和b的时候, 虚拟机就无法回收a和b. 这样久而久之就造成的内存溢出.
当然了, JVM的主流垃圾回收是利用可达性分析算法来实现的.那么可达性分析算法最重要的地方就在于GC ROOT(根节点). 所谓的"可达", 就是看待回收对象是否对GC ROOT可达. 初始会将所有GC ROOT放入一个集合(live list), 然后从初始集合开始, 找到所有能够被此集合引用到的对象, 并将其加入此集合中, 这个过程为mark(标记), 那么未被探索到的对象就是可以回收的.
GC ROOT, 主要有以下几种:
-
Java的栈桢中的局部变量(注意是栈桢中, 也就是正在执行的方法)
-
已经加载的类静态变量
-
JNI 操作相关
-
正在运行的Java 线程
上面的例子中, 对象a和b即使相互引用, 但是没有对任何GC ROOT可达, 所以他们是要被回收的.
听起来简单明了, 但是实际解决起来回复杂的多! 例如在多线程环境下, GC已经标记了一个对象为待回收对象, 那么另外的线程在GC线程回收前又来引用这个对象了, 那么这时候就造成了漏报, 如果GC已经把一个对象放入初始集合中, 但是其他线程已经放弃了对该对象的引用, 这个时候就会造成了误报.误报并不可怕, 造成的影响紧是该对象错过一次被回收的机会, 但是漏报的话就有可能造成Java虚拟机运行崩溃.
GC停顿和安全点
为了解决上面的问题, JVM采用了一个简单的办法, 即: **在垃圾回收时, 停止一切其他非GC线程, 知道垃圾回收结束, **这就是所谓的GC停顿(stop the world).
stop the world需要通过安全点机制来实现. 当收到stop the world请求,就会等所有线程到达安全点后, 才允许stop the world线程开始工作. 这时候是一个稳定的运行状态, JVM的内存空间并不会发生变化, 所以GC就可以安全的执行可达性分析了.
以下是一个具体的例子,Java程序通过JNI(Java native interface)调研本地方法, 如果这节代码不引用Java对象,不调用Java方法, 也不返回原Java方法, 那么此时的JVM内存对债不会发生任何变化, 所以这段代码可以作为一个安全点. 所以在进行垃圾回收的时候, 这段代码依然可以在安全点范围内继续运行.
除了上述例子以外, 还有下面几个安全点, 解释执行字节码, 执行即时编译器生成的机器码以及线程阻塞. 其他运行状态的安全点则是要保证虚拟机在可控的时间进入安全点. 否则GC线程会长期等待, 增加GC停顿时间.
垃圾回收的三种方式
执行标记过程后, 才是真正的进行对象回收.目前的垃圾回收方式主要有三种.
第一种是清除, 就是把死亡对象所占据的内存区域标记为空闲内存, 记录在一个空闲列表. 当建立新对象的时, 在该空闲列表中寻找内存, 划分给新对象. 这种方式很容易造成内存碎片, 但是jvm分配的对象必须是连续的,所以1M的对象,是没法分配在10个总大小是1M的碎片上的. 还有就是效率低, 如果连续只要指针做加法即可, 而碎片则需要逐个去访问.
image第二种是压缩, 就是把存货的对象放到一起, 挡道内存区域的起始位置, 从而留下一段儿连续的内存进空间, 解决了上面第一种的碎片问题, 但是带来的内存位置移动是需要西能开销的.
image第三中则是复制, 用两个指针,"from"和"to"来维护两个等分内存区域, 并且只是用from指向的北村区域来分配内存, 当发生GC时, 便把对象复制到to指针指向的那片内存区域中, 同时交换from和to两个指针的内容. 这种方式也解决了碎骗问题, 但是内存的的空间利用率低.
imageJava的分代垃圾回收思想
课程的老师在上博士的时候, 曾经对Java对象的生命周期进行过数据统计. 结果就是大部分的Java对象只存活一小段时间, 而存活下来的对象则大部分会存货很久. 正是因为这样的假设, 所以才造就了Java虚拟机的分代回收思想. 大家应给都知道, Java 堆(heap)才是GC工作的区域, 所以堆也叫做GC堆(请不要翻译成垃圾堆--此话引自<<深入理解Java虚拟机>>). JVM在堆中划分了两代, 分别为新生代和老年代. 新生代用来存储新建的对象, 当对象活的足够久, 就移动至老年代.
正是因为如此, Java虚拟机才会根据不同代, 来选取最合适的算法进行垃圾回收. 新生代会使用耗时较短且高效的算法, 以便让大部分存活时间短的对象被回收. 而老年代出发回收时, 则意味着堆的空间已经很紧张了.
JVM的堆划分
除了上面提到的新生代和老年代以外, 新生代又被分为Eden区, 和两个大小一样的survivor区(from...to). 默认情况下jvm自动来调配空间的大小, 例如根据生成对象的速率.
image当我们使用new
来创建一个对象时, JVM会在Eden区划分一段内存来存储对象. 但是堆不同于栈, 堆对于所有线程是共享的. 所以创建对象的时候, 就需同步操作, 否则就会出现两个对象指针指向同一个内存地址的问题, 所以这就引出了TLAB(thread local allocation buffer)的概念.
TLAB
其实就是每个线程会被分配一段预留的内存空间, 如果不够还可以申请, 这就是作为线程的私有空间也叫做私有TLAB. 对应的虚拟机参数为-XX:+UseTLAB
(默认开启). 所以线程要维护两个指针, 一个指向TLAB开始, 一个指向结束. 所以当new的时候, 就会做指针加法就可以了.
Minor GC
当Eden内存耗尽时候, 虚拟机会出发一次Minor GC, 来手机新生代的垃圾. 存活下来的对象会被送往Survivor区.
上面提到过, 新生代又两个Survivor区, 分别是from和to, 其中to所指向的区总是空的. 当发生Minor GC时, Eden区和from区的存活对象复制到to区, 然后交换from 和 to 的指针, 以便下次Minor GC时, to区时空的.
JVM会记录一个对象在Survivor区中被复制了多少次,如果被复制了15次(对应的虚拟机参数为-XX:+MaxTenuringThreshold
),那么这个对象就会被认为是要长期存在的,于是就将其放入老年代。如果单个Survivor的使用率已经超过50%(对应虚拟机参数 -XX:TargetSurvivorRatio
),那么复制次数较高的对象也会被放入老年代。
总之,Minor GC主要使用了标记-复制算法,把Survivor中的存活的老对象放入老年代,将Survivor区剩下的存活对象和Eden剩下的存活对象,复制到另一个区。在理想的情况下,大部分对象都在新生代通过Minor GC而消亡,所以这种算法是最为合适的。
但是当老年代的对象,对新生代的对象有引用的时候,那么这样的引用也会被作为GC ROOTS。如果是这样,Minor GC就不仅仅对新生代进行垃圾回收了,而是对整个堆空间进行扫描才行。对于这个问题,hotspot的解决办法为 card table。
Card Table
卡表的解决方案为,在整个堆空间分为一个个大小为512字节的卡,并且维护一个数据结构,来存储每一个卡的标记位。这个标记位代表对应是否可能存在对新生代对象的引用,如果有,这就是一张脏卡。所以在Minor GC时,就不需要对整个老年代进行扫描。而是在卡表中寻找脏卡,并将脏卡中的对象假如到GC ROOTS中,扫描所有脏卡后,就会对标记位进行清零。
Minor GC伴随着对象的复制,复制需要更新引用该对象的地址。所以与此同时,也要更新设置引用卡所在的标记位。这时可以确保脏卡中一定要包含指向新生代对象的引用。
但是在Minor GC之前,我们是无法确保脏卡中一定包含指向新生代的引用的,这与脏卡的标记位设置有关的。
假设要保证每一个对新生代的引用都标记位脏卡,那么肯定需要在每个引用型实例对象操作之前,先做一些拦截工作,以便提前写好标记位。这就是所谓的写屏障。
网友评论