本来是讲内存优化的,但是发现绕不开java虚拟机,而java虚拟机内容又太多,只能独立先写一篇java虚拟机。
上面这张图表示的是:java虚拟机在运行时的数据区域。也就是说这是android app跑起来后,在内存中的状态。
蓝色区域(方法区, Java堆),是所有线程都可以访问,都是共享的。像Handler就是利用了这一点,message queue存在java堆,所以在任何线程里都可以send message 到queue。
白色区域(Java虚拟机栈,本地方法栈, 程序计数器), 每一个线程都各自的区域,互不干扰。
方法区
它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
类型信息。类型信息是由类加载器在类加载时从类文件中提取出来的。就是Class.java类的实例对象。通过下面这个静态native方法从虚拟机中获取的。方法声明也是在Class.java中。
/** Called after security checks have been made. */
@FastNative
static native Class<?> classForName(String className, boolean shouldInitialize,
ClassLoader classLoader) throws ClassNotFoundException;
- 类型的完整有效名。在java源代码中,完整有效名由类的所属包名加一个".",再加上此类名。比如View类,完整有效名:android.view.View。 但在class文件中用“/”替代“.”,是android/view/View。
- 直接父类的完整有效名(除非这个类型是interface或是java.lang.Object,两种情况下都没有父类) 。
- 这个类型的修饰符(public,abstract, final的某个子集) 。
- 类型直接接口的一个有序列表 。
除了以上的基本信息外,jvm还要为每个类型保存以下信息:
- 类型的常量池( constant pool)
- 域(Field)信息
- 方法(Method)信息
- 除了常量外的所有静态(static)变量
可以用这些方法获取:
// Some of the methods declared in class java.lang.Class:
public String getName();
public Class getSuperClass();
public boolean isInterface();
public Class[] getInterfaces();
public ClassLoader getClassLoader();
类的静态变量
在jvm使用一个类之前,它必须在方法区中为每个non-final类变量分配空间。 所以static变量不能太大,太多。因为无法回收,还会导致启动慢。
类型的常量池( constant pool)
用于存放编译期间生成的各种字面量与符号引用。这部分内容将在类加载后存放到方法区的运行时常量池中。
运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
运行时常量池中包含多种不同的常量, 包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。
此时不再是常量池中的符号地址了,这里换为真实地址。运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。而运行时常量池期间也有可能加入新的常量(如:String.intern方法)
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM也会抛OutOfMemoryError异常。
域(Field)信息
jvm必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。是通过下面这两个native方法从虚拟机中获取的。方法声明也是在Class.java中。
* @since JDK1.1
* @jls 8.2 Class Members
* @jls 8.3 Field Declarations
*/
// Android-changed: Removed SecurityException
@FastNative
public native Field getDeclaredField(String name) throws NoSuchFieldException;
/**
* Returns the subset of getDeclaredFields which are public.
*/
@FastNative
private native Field[] getPublicDeclaredFields();
域的相关信息包括:
域名
域类型
域修饰符(public, private, protected,static,final volatile, transient)
方法信息
jvm必须保存所有方法的以下信息,也包括声明顺序。是通过下面这两个native方法从虚拟机中获取的。方法声明也是在Class.java中。
/**
* Populates a list of methods without performing any security or type
* resolution checks first. If no methods exist, the list is not modified.
*
* @param publicOnly Whether to return only public methods.
* @hide
*/
@FastNative
public native Method[] getDeclaredMethodsUnchecked(boolean publicOnly);
方法名
方法的返回类型(或 void)
方法参数的数量和类型(有序的)
方法的修饰符(public, private, protected, static, final, synchronized, native, abstract)。除了abstract和native方法外,其他方法还保存方法的字节码(bytecodes)操作数栈和方法栈帧的局部变量区的大小 。
类加载器的引用
jvm必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。
/** defining class loader, or null for the "bootstrap" system loader. */
private transient ClassLoader classLoader;
public ClassLoader getClassLoader() {
if (isPrimitive()) {
return null;
}
// Android-note: The RI returns null in the case where Android returns BootClassLoader.
// Noted in http://b/111850480#comment3
return (classLoader == null) ? BootClassLoader.getInstance() : classLoader;
}
jvm在动态链接的时候需要这个信息。当解析一个类型到另一个类型的引用的时候,jvm需要保证这两个类型的类加载器是相同的。这对jvm区分名字空间的方式是至关重要的。
Class类的引用
jvm为每个加载的类型(包括类和接口)都创建一个java.lang.Class的实例。而jvm必须以某种方式把Class的这个实例和存储在方法区中的类型数据联系起来。
Java堆
Java堆是 JVM 所管理的最大的一块内存空间,用于存放各种类的实例对象。也是内存优化的主战场。
垃圾内存标记算法
垃圾内存的标记算法,有引用计数法,根搜索算法(GC Root)。
- 引用计数法
每一个对象都有一个引用计数器,当对象每被引用一次时就加1,引用失效时就减1。当计数为0时2则将该对象设置为可回收的“垃圾对象”。
但是引用计数法,有一个致命的缺点。就是相互引用的问题。看下面实例:
当b1,b2被new的时候,他们的引用计数就加1。接下来b1.b = b2; b2.b= b1; 引用计数再次加1。此时b1,b2的引用计数都是2。
问题来了,当b1 = null, b2 = null时,引用计数只减了1。如果现在调用gc, 那么b1,b2是无法被回收的。
想想如果要正确回收,还必须要这样做:
b1.b = null;
b2.b= null;
b1 = null;
b2 = null;
是不是非常麻烦?!
- 根搜索算法(GC Root)
在堆的引用结构树中,GC root是树根,如果叶子节点无法到达树跟,就是被标记为垃圾。也就是说从GC root的进行可达性判断是否是垃圾内存。
java中就是用了根搜索算法。
常见的GC Root有如下:
- 通过System Class Loader或者Boot Class Loader加载的class对象,通过自定义类加载器加载的class不一定是GC Root。
- 处于激活状态的线程。
- 栈中的对象。
- JNI栈中的对象。
- JNI中的全局对象。
- 正在被用于同步的各种锁对象。
- JVM自身持有的对象,比如系统类加载器等。
- 静态变量。
object5, object6, object7此时已经无法到达GC Root Set里面任何一个GC Root对象。所以会被垃圾回收。
我们通常说的内存泄漏, 比如object4, 虽然还能到达GC Root对象,但是实际已经不被程序需要了,可又无法回收,这就造成了内存泄漏。
垃圾内存回收算法
前面我们讲到的是垃圾标记,现在垃圾已经标记好了,要开始回收了。那么回收有哪些方法呢?
- 标记-清除算法
对已经被标记为了“垃圾"的内存进行回收。这是最简单的算法。
上图,解释了标记-清除算法的过程。 有没有发现会造成严重的内存碎片。这是这个算法的致命缺点。
- 复制算法
先把内存一分为二,每次只使用其中一个区域,垃圾收集时,将存活的对象全部拷贝到另外一个区域,然后对之前的区域进行全部回收。
复制算法,虽然解决了内存碎片的问题,可同时可用内存也减少了一半。
- 标记压缩算法
在标记可回收的对象后,将所有的存活的对象压缩到内存的一端,让他们排在一起,然后对外界以外的内存进行回收。
这个算法很好的解决了内存碎片,也没有复制算法的问题。但是会导致频繁复制粘贴内存块。
- 分代回收算法
Java主要使用的是分代回收算法。前面讲了,标记清理算法,复制算法,标记压缩算法,是因为分代算法要用到了它们。
Java堆中存在的对象生命周期有较大差别,大部分生命周期很短,有的很长,甚至与应用程序或者Java虚拟机生命周期一样。所以采用分代回收算法比较合适。
分代算法就是根据对象的生命周期长短,将内存划分为几个区域,不同的区域采用合适的垃圾回收算法。
Java堆内存被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成。
在老年代中因为对象存活率高、没有额外空间对他进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。
下面讲解分代算法的过程:
- 当系统创建一个对象的时候,总是在Eden区操作,当这个区满了,那么就会触发一次minor GC,也就是年轻代的垃圾回收。
- 一般来说这时候不是所有的对象都没用了,所以就会把还能用的对象复制到From区。
- 这样整个Eden区就被清理干净了,可以继续创建新的对象,当Eden区再次被用完,就再触发一次minor GC,然后呢,注意,这个时候跟刚才稍稍有点区别。这次触发YoungGC后,会将Eden区与From区还在被使用的对象复制到To区.
- 再下一次minor GC的时候,则是将Eden区与To区中的还在被使用的对象复制到From区。
- 经过若干次minor GC后,有些对象在From与To之间来回游荡,当游荡次数超过了阈值时(通过参数 -XX:MaxTenuringThreshold 来设定),这些家伙要是到现在还没挂掉,对不起,一起滚到(复制)老年代吧。
- 老年代经过这么几次折腾,也就扛不住了(空间被用完),好,那就来次full GC, 来次集体大扫除(full GC),收集整个java堆和方法区。
- System.gc() 调用的是full GC,但不是马上会执行。
- minor GC不会让所有线程停下来,但是major GC或full GC会。也是UI卡顿的原因。
full gc是相对于partial gc(部分gc而言),而partial gc则包括minor gc(新生代收集,也叫young gc)、major gc(老年代收集,也叫old gc)和mixed gc(混合gc)后两者发生的情况比较少,具体看是哪种收集器,当说到major gc时,要根据上下文去辨别究竟指的是old gc还是full gc,原因还是前面那个原因,所以这个词的说法有点混淆。
ART回收机制
ART运行时内部使用的Java堆的主要组成包括Image Space、Zygote Space、Allocation Space和Large Object Space四个Space,Image Space用来存在一些预加载的类, Zygote Space和Allocation Space与Dalvik虚拟机垃圾收集机制中的Zygote堆和Active堆的作用是一样的, Large Object Space就是一些离散地址的集合,用来分配一些大对象从而提高了GC的管理效率和整体性能,比如bitmap.
GC的类型
kGcCauseForAlloc ,当要分配内存的时候发现内存不够的情况下引起的GC,这种情况下的GC会stop the world.
kGcCauseBackground,当内存达到一定的阀值的时候会去出发GC,这个时候是一个后台gc,不会引起stop the world.
kGcCauseExplicit,显示调用的时候进行的gc,如果art打开了这个选项的情况下,在system.gc的时候会进行gc.
GC_BEFORE_OOM,在OOM之前最后的努力了
GC日志
2021-02-02 10:36:50.993 1401-1416/? I/system_server: Background concurrent copying GC freed 241246(18MB) AllocSpace objects, 57(5768KB) LOS objects, 36% free, 41MB/65MB, paused 77us total 186.756ms
gc reason:就是我们上文提到的,是gc_alloc还是gc_concurrent,了解到不同的原因方便我们做不同的处理。
freed :表示系统通过这次GC操作释放了多少内存
heap states:中会显示当前内存的空闲比例以及使用情况(活动对象所占内存 / 当前程序总内存。也就是:36% free, 41MB/65MB
paused :表示这次GC操作导致应用程序暂停的时间。关于这个暂停的时间,在2.3之前GC操作是不能并发进行的,也就是系统正在进行GC,那么应用程序就只能阻塞住等待GC结束。而自2.3之后,GC操作改成了并发的方式进行,就是说GC的过程中不会影响到应用程序的正常运行,但是在GC操作的开始和结束的时候会短暂阻塞一段时间,所以还有后续的一个total。
total : 表示本次GC所花费的总时间和上面的Pause_time,也就是stop all是不一样的,卡顿时间主要看上面的pause_time。
程序计数器
程序计数器用来记录所在线程执行到什么地方了。
程序计数器一块很小的内存空间,也是唯一个在虚拟机中不会OutOfMemoryError情况的区域。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的。在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。所以程序计数器是每一个线程的独立内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是native方法,这个计数器值则为空(Undefined)。
Java虚拟机栈
与程序计数器一样,Java虚拟机栈也是线程私有的,生命周期也与线程一样。
虚拟机栈描述的是Java方法执行的内存模型:每一个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表存放了编译期可知的各种基本数据类型(boolean, byte,char, short, int ,float, long double),对象引用地址。
Java虚拟机栈有两种溢出情况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。
- 如果虚拟机栈可以动态扩展, 但是当扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
以上所有关于Java虚拟机运行时的数据状态。