作为Android开发工程师,对内存管理一定不陌生,因为在Android机制中,代码不规范很容易引起内存泄露。那么我们沿着这个问题推出一些问题:
1.内存泄露会导致什么?
2.内存泄露的原理是什么?
3.jvm里边的内存模型是什么?
4.jvm里边的内存怎么管理的?
5.怎么标记一个对象是否可回收?
6.具体讲讲GC的算法?
7.那些对象可以作为GCRoot?
8.怎么预防内存泄露?
......
我们在面试的时候,经常会被问到关于jvm内存的各种细节知识?你真的都能回答上来吗?你能条理清晰的说出来吗?如果你也是懵懵懂懂,就跟着我一起复习这些知识吧。
一、JVM运行时数据区(内存)
JVM在运行过程中会把所管理的内存划分成若干不同的数据区域,按照线程能否共享分为:1.线程共有:堆 方法区2.线程私有:虚拟机栈 本地方法栈 程序计数器
1、程序计数器
程序计数器指向当前线程正在执行的字节码指令的地址,这句话我们经常在博客或者书本上看到,可是他的作用是什么呢?我们知道Java是支持多线程操作的,那么CPU就会经常切换线程,为了确保多线程情况下的程序正常进行就需要程序计数器的工作了。我们还画张图:
上图的讲解,当线程1在看电视的时候,突然快递员敲门,这时候,线程1的程序计数器就会记下你看的电视进度,切换到线程2开门,开完门后(线程2结束),线程调度切回到线程1,接着上次的进度继续看电视。
2、JVM栈和本地方法栈
我们先看一下,栈的数据结构特点是:
1.入口和出口只有一个
2.先进后出(First In Last Out)
2.1、虚拟机栈
存储当前线程运行方法所需的数据,指令、返回地址等。类中的每一个方法对应一个栈帧,栈帧主要结构:
局部变量表,操作数栈,动态链接,返回地址
我们结合一段代码,对栈帧结构进行讲解:
public classJavaStack{
//静态变量 static String ls = "XXX网络培训";
//常量 final String Fs = "全部课程五折";
publicvoidstudy(){
//局部变量 Object o = new Object();
int ALIPay = 50;
int weixin = 50;
if (ALIPay+weixin>80) {
o.hashCode();
ALIPay -=30;
weixin-=50;
}
System.out.println("ALIPay="+ALIPay+",weixin="+weixin);
}
publicstaticvoidmain(String[] args){
new JavaStack().study();
}
}
然后进行反编译javap -v JavaStack.java > stack.txt,然后打开stack.txt,大家打开后会看不懂,我们可以去网上找一下 Java字节码指令大全,参考着指令表,就能够看懂一些。
2.1.1、局部变量表
2.1.2、操作数栈就是对帧的入栈和出栈
2.1.3、动态链接指的方法的多态
2.1.4、返回地址
3.内存分配
3.1线程私有内存
线程私有内存的生命周期是跟随线程的,这里主要讲解虚拟机栈的内存。虚拟机栈的大小可以通过-Xss设置,默认是1M。内存溢出的情况:当程序调用无限递归的时候JVM会抛出StackOverflowError,这时候只能通过检查代码逻辑修改。
3.2线程共有内存
还是结合上边的代码示例,内存分配如下图,其中机器码指的是在机器上运行的二进制编码:
4.JVM内存模型(JMM)
JVM在程序运行过程当中,会创建大量的对象,这些对象,大部分是短周期的对象,小部分是长周期的对象,对于短周期的对象,需要频繁地进行垃圾回收以保证无用对象尽早被释放掉;对于长周期对象,则不需要频率垃圾回收以确保无谓地垃圾扫描检测。为解决这种矛盾,JVM的内存管理采用分代的策略。
堆分为:
新生代
Eden区
From Survivor空间
To Survivor空间
老年代
这样划分的目的是为了使JVM能够更好的管理堆内存中的对象,包括内存的分配和回收。
方法区
永久代(jdk<1.8),元空间(jdk>=1.8)
本文主要讲述堆内存模型,方法区不作为重点讲解。
堆的大小可以通过参数 –Xms、-Xmx 来指定。默认的新生代和老生代的内存比例是1:2(该值可以通过-XX:NewRadio设置),即新生代内存大小是1/3,其中新生代中的Eden和From To比例为 8:1:1;JVM只会使用Eden和其中一块Survivor区域来为对象服务,所以无论什么时候都有一块Survivor是空闲的。
JVM中的堆也是GC主要的回收区域,GC分为:Minor GC,Full GC,两者具体的回收算法留在后边讲解,这里先讲解内存的动态分配情况。
MinorGC是发生在新生代中的垃圾收集动作,所采用的是复制算法,原因会在后边讲解。
新生代几乎是所有对象出生的地方,在Java中的大部分对象都不是长久存活的。当一个对象被判定为可回收的时候,GC就有责任回收掉对象的内存空间。新生代是GC手机垃圾的频繁区域。
当对象在 Eden ( 包括一个 Survivor 区域,这里假设是 from 区域 ) 出生后,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳( 上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代。
在MinorGC回收后,要复制对象到To区域,但是to 区域已经满了,那么久直接将To区域的对象晋级到老年代中(即动态对象年龄判断)
Full GC 是发生在老年代的垃圾收集动作,所采用的是标记-清除算法和标记整理算法。堆内存中老年代(Old)的对象几乎个个都是在 Survivor 区域中熬过来的,它们是不会那么容易就 "死掉" 了的。因此,Full GC 发生的次数不会有 Minor GC 那么频繁,并且做一次 Full GC 要比进行一次 Minor GC 的时间更长。另外,标记-清除算法收集垃圾的时候会产生许多的内存碎片 ( 即不连续的内存空间 ),此后需要为较大的对象分配内存空间时,若无法找到足够的连续的内存空间,就会提前触发一次 GC 的收集动作。
所以内存分配的总结如下:
对象和数组优先在Eden分配,
大对象直接进入老年代(比如大容量的数组需要一块连续的内存空间,这时候Eden内存不够)
长期存活的对象将进入老年代(年龄=15 不被垃圾回收)
动态对象年龄判断
5.如何判断一个对象是否可回收?
常用方法有:引用计数算法,可达性分析
引用计数很好理解这里不在讲述,而且当两个对象互相引用的时候引用计数算法就会出问题。这里主要讲解一下可达性分析,它把内存中的每一个对象都看作一个节点,并且定义了一些对象作为根节点“GC Roots”。如果一个对象中有另一个对象的引用,那么就认为第一个对象有一条指向第二个对象的边,如下图所示。JVM会起一个线程从所有的GC Roots开始往下遍历,当遍历完之后如果发现有一些对象不可到达,那么就认为这些对象已经没有用了,需要被回收。
如上图Object6,Object7,Object8都是可回收对象
其中能够作为GCRoot对象有如下四种:
1.方法区:类静态属性引用的对象
2.方法区:常量引用的对象
3.虚拟机栈(本地变量表)中的对象
4.本地方法栈中引用的对象
6.垃圾回收算法
垃圾回收算法分为标记-清除算法,标记-整理算法和复制算法
6.1、标记-清除算法
该算法分为“标记”和“清除”两个阶段:首先标记出所需要回收的对象,在标记完成后统一回收所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思想并对其不足之处进行改进而已。它的主要不足之处有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除后悔产生大量的不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象是,无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作。执行过程如图所示
6.2、标记-整理算法
标记-整理算法,标记过程仍然和“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行整理,而是让所有存活的对象向一端移动,然后直接清理掉边界以外的内存。“标记-整理”算法的执行过程如图所示
6.3、复制算法
复制算法是将内存按容量大小划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。复制算法在对象存活率较高时就要进行较多的复制操作,操作效率会变低,所以不适合老年代。执行过程如下图
本篇文章主要讲解JVM中相关的内存知识,关于开篇提到的Android内存泄露的问题,大家可以去Google Android开发者官网上找到。