内存分配
文章目录
1. 前言
1.1 类、对象、实例
类:类是一个模板,它描述一类对象的行为和状态。
对象:对象是类的一个实例,有状态和行为,实例就是对象。
举个栗子:People是一个java类,其中有一个sex属性。男人-new People(“男人”)、女人-new People(“女人”)就是People类的两个对象(实例)。
People p = new People("男人");
new People("男人")
new出来的东东就是对象,也可以成为People类的实例。而p
则是对象的引用,说白了就是类的对象的变量。
建议看看《Thinking in Java》。
1.2 对象的创建
1.3 引用的分类
- 强引用:类似于
Object o = new Object()
这类引用,只要强引用还在,垃圾收集器永远不会回收被引用的对象。 - 软引用:软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。
- 弱引用:弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。
- 虚引用:虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。
2. GC常见配置及日志分析
2.1 GC配置
-XX:+PrintGCDetails
:打印GC详细信息,包含了-verbose:gc
的作用
2.2 GC分类
- Minor GC:新生代GC,因为大多数的对象具有朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
- Major GC:Full GC,老年代GC,出现了Major GC,经常会伴随着至少一次的Minor GC(但非绝对,看垃圾收集器),Major GC的速度一般会比Minor GC慢10倍以上。
2.3 GC日志分析
3. 内存分配算法
3.1 指针碰撞
假设虚拟机的堆内存是绝对规整的,所有用过的内存在一边,空闲的内存在另一边,中间分界点放着一个指针。分配内存就是将指针向空闲内存移动一段与对象大小相等的距离,这种分配方式就称为指针碰撞
。
3.2 空闲列表
如果虚拟机的堆内存是不规整的,已经使用的内存和空闲的内存相互交错,这个时候就没有办法进行简单的指针碰撞
了,虚拟机就必须要维护一个列表,记录那块内存是使用过的,那块内存是空闲的。在分配的时候,找到一块足够大的空间划分给对象,并更新列表上的记录,这种分配方式就称为空闲列表
。
3.3 内存分配小结
分配方式是由虚拟机堆内存是否规整决定,而堆内存是否规整是由所采用的垃圾收集器是否电邮压缩整理功能决定。因此,使用 Serial、ParNew 等带 Compact 过程的收集器时,系统采用的分配算法是指针碰撞,而是用 CMS 这种基于 Mark-Sweep 算法的收集器时,通常采用空闲列表。
3.4 内存分配时的原子性
对象创建在虚拟中是一个非常频繁的操作,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。解决这个问题的方案有两个,一个是对分配内存空间的动作进行同步处理(CAS+失败重试),另一种就是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(ThreadLocal Allocation Buffer, Tlab)。哪一个线程分配内存,就在哪一个线程的Tlab上分配,只有Tlab用完,并分配新的Tlab时,才需要同步锁定。虚拟机是否使用Tlab,可以通过-XX:+/-UseTLAB
参数设定。
4. 内存分配规则
4.1 对象优先在Eden分配
大多数情况下,对象优先在Eden区中分配,如果虚拟机开启了TLAB,则先在TLAB上分配。
TLAB的结构如下:
// ThreadLocalAllocBuffer: a descriptor for thread-local storage used by
// the threads for allocation.
// It is thread-private at any time, but maybe multiplexed over
// time across multiple threads. The park()/unpark() pair is
// used to make it avaiable for such multiplexing.
class ThreadLocalAllocBuffer: public CHeapObj<mtThread> {
friend class VMStructs;
private:
HeapWord* _start; // address of TLAB
HeapWord* _top; // address after last allocation
HeapWord* _pf_top; // allocation prefetch watermark
HeapWord* _end; // allocation end (excluding alignment_reserve)
size_t _desired_size; // desired size (including alignment_reserve)
size_t _refill_waste_limit; // hold onto tlab if free() is larger than this
从本质上来说,TLAB的管理是依靠三个指针:start、end、top。start与end标记了Eden中被该TLAB管理的区域,该区域不会被其他线程分配内存所使用,top是分配指针,开始时指向start的位置,随着内存分配的进行,慢慢向end靠近,当撞上end时触发TLAB refill。
因此内存中Eden的结构大体为:
当Eden区没有足够的空间,虚拟机将发起一次Minor G,在Minor GC存活下来的对象,会被复制到Survivor区中。如果Surivor区不足以存放本次GC后存活对象,将通过分配担保机制提前转移到老年代中去。
4.2 大对象直接进入老年代
所谓大对象是指,需要大量连续内存空间的java对象,如很长的字符串以及数组。
4.3 长时间存活的对象将进入老年代
虚拟机为每个对象定义了一个对象年龄的计数器,如果对象在Eden区出生,经过第一次Minor GC后任然存活,并能被Survivor区容纳的话,将这个对象移动到Survivor区中,并设置年龄为1。
对象在Survivor区中每“熬过”一次Minor GC,年龄就会+1岁,当它的年两到达一定程度时(默认15岁),就将会被晋升到老年代中。这个阈值可以通过参数-XX:MaxTenuringThreshold
设置。
4.4 动态年龄的判定
为了能够更好的适应不同程序的内存情况,虚拟机并不是永远的要求对象的年龄必须达到MaxTenuringThreshold才能够晋升老年代,如果在Survivor区中相同年龄对象的大小之和>Survivor区的一半,年龄大于或者等于该年龄的对象就可以提前进入老年代。
4.5 空间分配担保
发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
下面解释一下“冒险”是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,仍然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC 过于频繁
以上章节参考:《深入理解Java虚拟机第2版》
4.6 总结:一个对象的一生
我是一个普通的Java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。直到我16岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次GC加一岁),然后被回收。
上述自述摘自:https://blog.csdn.net/u012799221/article/details/73180509
5. 疑问
5.1 为什么要有 Survivor 区?
如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Full GC。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。
你也许会问,执行时间长有什么坏处?频发的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度,更不要说某些连接会因为超时发生连接错误了。
在没有Survivor的情况下,有没有什么解决办法,可以避免上述情况:
方案 | 优点 | 缺点 |
---|---|---|
增加老年代空间 | 更多存活对象才能填满老年代。降低Full GC频率 | 随着老年代空间加大,一旦发生Full GC,执行所需要的时间更长 |
减少老年代空间 | Full GC所需时间减少 | 老年代很快被存活对象填满,Full GC频率增加 |
显而易见,没有Survivor的话,上述两种解决方案都不能从根本上解决问题。
5.2 为什么 Survivor 要分成两个区域?
假设现在只有一个survivor区,我们来模拟一下流程:
刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。
假设现在有两块Survivor区,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块 From Survivor S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块 To Survivor S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。S0和Eden被清空,然后下一轮S0与S1交换角色,如此循环往复。如果对象的复制次数达到16次,该对象就会被送到老年代中。