在Android诞生至今,也已经有十年了。整个开发社区也更加的活跃。在高速的信息发展时代,社区的各个成员也将自身的知识分享出去,对于一些功能的开发,网上也充斥着各种解决方案。我们往往能在网上找到适合自身功能的解决方案,在完成功能之后,却往往对功能模块的稳定性和相关性能进行测试。在如今的时代中,面对大量“年久失修”的历史代码,又引入不同跨平台方案的应用,我们又该如何打造一款高质量的应用呢。
及时Android发展了10余年,内存带来的各种问题从史贯穿至今,即使现在高性能的手机已经推出了8GB,4GB的内存,但内存问题依然影响着我们。在日常开发过程中,大家肯定或多或少都接触过内存优化,以及内存所带来的问题。我们将从理解内存、内存产生的问题到实战如何解决内存来一起探索Android中的内存优化。
本篇文章主要来理解java中对象内存的分配和使用。
Android 运行时 (ART) 和 Dalvik 虚拟机使用分页和内存映射来管理内存。这意味着应用修改的任何内存,无论修改的方式是分配新对象还是轻触内存映射的页面,都会一直驻留在 RAM 中,并且无法换出。要从应用中释放内存,只能释放应用保留的对象引用,使内存可供垃圾回收器回收。
手机运行内存(RAM)其实相当于我们的 PC 中的内存,是手机中作为 App 运行过程中临时性数据暂时存储的内存介质。不过考虑到体积和功耗,手机不使用 PC 的 DDR 内存,采用的是 LPDDR RAM,全称是“低功耗双倍数据速率内存”,其中 LP 就是“Lower Power”低功耗的意思。手机系统内存是指手机运行程序时使用的内存(即运行内存),只能临时存储数据,用于与CPU交换高速缓存数据,但是随机存储器(RAM)本身不能用于长期存储数据。
LPDDR系列的带宽 = 时钟频率 ✖️内存总线位数 / 8
LPDDR4 = 1600MHZ ✖️64 / 8 ✖️双倍速率 = 25.6GB/s。
内存类型 分页 误区
当系统内存充足的时候,我们可以多用一些获得更好的性能。当系统内存不足的时候,希望可以做到”用时分配,及时释放“。
大家肯能都或多或少对Android内存管理机制有所了解,JVM拥有垃圾内存回收机制,自身会在虚拟机层面自动分配和释放内存,因此不需要使用C/C++一样在代码中可以直接控制分配和释放某一块内存。即便有了内存自动管理机制,但是在我们日常的业务开发中稍有不慎,不合理的使用内存,也会造成一系列的性能问题,比如内存泄漏,内存抖动,短时间内分配大量的内存对象等。
Android中我们使用的有java对象,所以需要了解下JVM相关的知识。
Java 中的运行时数据可以划分为两部分,一部分是线程私有的,包括虚拟机栈、本地方法栈、程序计数器,另一部分是线程共享的,包括方法区和堆。
区域名称 | 作用 |
---|---|
程序计数器 | 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。计算当前线程的当前方法执行到多少行 |
虚拟机栈 | 虚拟机栈描述的是 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接地址、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈桢在虚拟机中入栈和出栈的过程。 |
本地方法栈 | native变量引用,Native 方法执行的内存模型 |
java堆 | Java 堆是所有线程共享的一块数据区域,主要用来存放对象实例。它也是垃圾收集器管理的主要区域。 |
方法区 | 存储已被虚拟机加载的类信息、常量、静态变量、即使编译器编译后的代码等数据 |
Java 内存模型规定了所有的共享变量都是存储在主内存,每个线程还有自己的工作内存,线程的工作内存保存了该线程使用到的共享变量的主内存副本拷贝,线程对变量的操作都必须在工作内存中进行,而不能直接读写主内存中的变量,不同的线程之间也无法直接访问对方工作内存中的数据,线程间变量值的传递均需要主内存来完成。
对象的创建是我们经常要做的事,通常是通过new 指令来完成一个对象的创建的。
当虚拟机接收到一个new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载过了,如果没有就走类加载流程。
在类加载检查通过之后,虚拟机就会为新生对象分配内存,对象所需内存在类加载完成之后就确定了。内存分配根据Java堆是否规整决定,指针碰撞和空闲列表
对象创建在虚拟机是非常频繁的行为,所以需要解决并发问题,解决方案有两种,一种是采用CAS算法并配上失败尝试的方式保证更新操作的原子性,另一种是使用线程私有的分配缓冲区TLAB
初始化分配的内存,除了对象头外斗初始化位零值
设置对象的对象头,将对象的所属类、对象的Hashcode和对象的GC分代年龄等数据存储在对象的对象头中。
对象创建完毕,并且已经在Java堆中分配了内存,对象在对内存布局分为三个区域 ,分别是对象头、实例数据、对齐填充。可以使用 OpenJDK 开源的 JOL 工具查看对象的内存布局,直接 new Object 所占用的大小为 16 字节,即 12 个字节的对象头 + 4 个字节的对其填充。JOL 对分析集合源码扩容、HashMap 的 hash 冲突等非常有用。
Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象,由于 reference 类型在 Java 虚拟机规范中只规定了一个指向对象的引用,并没有规定这个引用应该通过什么方式去定位和访问堆中的对象,所以对象访问方式也是取决于虚拟机实现而定。
目前主流的方式有使用句柄和直接指针两种。
使用句柄,就是相当于加了一个中间层,在对象移动时只会改变句柄中的实例数据的指针,reference 本身不需要改变。HotSpot 使用的是第二种,使用直接指针的方式访问的最大好处就是速度很快。
在垃圾收集器回收对象时,先要判断对象是否已经不再使用了,有引用计数法和可达性分析两种
引用计数法
引用计数法就是给对象添加一个引用计数器,每当有一个地方引用时就加一,引用失效时就减一。
引用计数实现简单,判断效率也很高,但是他很难解决对象之间的相互循环引用问题。所以JVM并未采用
可达性分析
可达性分析的思路是通过一系列称为 GC Roots 的对象作为起始点,从这些起始点出发向下搜索,当有一个对象到 GC Roots 没有任何引用链时,即不可达,则说明此对象是不可用的。在 Java 中,可作为 GC Roots 的对象有虚拟机栈和本地方法栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象等。
Java 中的引用可以分为四类,强引用、软引用、弱引用和虚引用。
强引用在程序中普遍存在,类似 new 的这种操作,只要有强引用存在,即使 OOM JVM 也不会回收该对象。软引用是在内存不够用时,才会去回收,JDK 提供了 SoftReference 类来实现软引用。弱引用是在 GC 时不管内存够不够用都会去回收的,可以使用 WeakReference 类来实现弱引用。虚引用对对象的生命周期没有影响,只是为了能在对象回收时收到一个系统通知,可以使用 PhantomReference 类来实现虚引用。
垃圾回收算法主要有标记清除、复制算法、标记整理、分代收集算法
标记清除是先通过GC Roots 标记所存活的对象,然后再统一清除未标记的对象,它的主要问题是会产生内存碎片。老年代使用的CMS收集器就是基于标记清除算法
复制算法是把内存空间划分为两块,每次分配对象只在一块内存上进行分配,在这一块内存使用完时,就直接把存活的对象复制到另外一块上,然后把已使用的那块空间一次清理掉,但是这种算法的代价就是内存的使用量缩小了一半。
标记整理算法即是在标记清除之后,把所有存活的对象都向一端移动,然后清理掉边界以外的内存区域
分代收集算法把Java堆区分为新生代和老年代,其中新生代划分为了一个 Eden 区和两个 Survivor 区,比例是 8:1:1,每次使用 Eden 和其中一块 Survivor 区,也就是只有 10% 的内存会浪费掉。如果 Survivor 空间不够用,需要依赖其他内存比如老年代进行分配担保。复制算法在对象存活率比较高时效率是比较低下的,所以老年代一般不使用复制算法。
Android 运行时 (ART) 和 Dalvik 虚拟机使用分页和内存映射来管理内存。这意味着应用修改的任何内存,无论修改的方式是分配新对象还是轻触内存映射的页面,都会一直驻留在 RAM 中,并且无法换出。要从应用中释放内存,只能释放应用保留的对象引用,使内存可供垃圾回收器回收。
Android 平台在运行时不会浪费可用的内存。它会一直尝试利用所有可用内存。例如,系统会在应用关闭后将其保留在内存中,以便用户快速切回到这些应用。因此,通常情况下,Android 设备在运行时几乎没有可用的内存。要在重要系统进程和许多用户应用之间正确分配内存,内存管理至关重要。
Android 有两种处理内存不足情况的主要机制:内核交换守护进程和低内存终止守护进程。
内核交换守护进程 (kswapd) 是 Linux 内核的一部分,用于将已使用内存转换为可用内存。当设备上的可用内存不足时,该守护进程将变为活动状态。Linux 内核设有可用内存上下限阈值。当可用内存降至下限阈值以下时,kswapd 开始回收内存。当可用内存达到上限阈值时,kswapd 停止回收内存。
kswapd 可以删除干净页来回收它们,因为这些页受到存储器的支持且未经修改。如果某个进程尝试处理已删除的干净页,则系统会将该页面从存储器复制到 RAM。此操作称为“请求分页”。
很多时候,kswapd 不能为系统释放足够的内存。在这种情况下,系统会使用 onTrimMemory() 通知应用内存不足,应该减少其分配量。如果这还不够,内核会开始终止进程以释放内存。它会使用低内存终止守护进程 (LMK) 来执行此操作。
LMK 使用一个名为 oom_adj_score 的“内存不足”分值来确定正在运行的进程的优先级,以此决定要终止的进程。最高得分的进程最先被终止。后台应用最先被终止,系统进程最后被终止。下表列出了从高到低的 LMK 评分类别。评分最高的类别,即第一行中的项目将最先被终止
以下是上表中各种类别的说明:
后台应用:之前运行过且当前不处于活动状态的应用。LMK 将首先从具有最高 oom_adj_score 的应用开始终止后台应用。
上一个应用:最近用过的后台应用。上一个应用比后台应用具有更高的优先级(得分更低),因为相比某个后台应用,用户更有可能切换到上一个应用。
主屏幕应用:这是启动器应用。终止该应用会使壁纸消失。
服务:服务由应用启动,可能包括同步或上传到云端。
可觉察的应用:用户可通过某种方式察觉到的非前台应用,例如运行一个显示小界面的搜索进程或听音乐。
前台应用:当前正在使用的应用。终止前台应用看起来就像是应用崩溃了,可能会向用户提示设备出了问题。
持久性(服务):这些是设备的核心服务,例如电话和 WLAN。
系统:系统进程。这些进程被终止后,手机可能看起来即将重新启动。
原生:系统使用的极低级别的进程(例如,kswapd)。
设备制造商可以更改 LMK 的行为。
Android 可以通过多种方式从应用中回收内存,或在必要时完全终止应用,从而释放内存以执行关键任务。为了进一步帮助平衡系统内存并避免系统需要终止您的应用进程,您可以在 Activity 类中实现 ComponentCallbacks2 接口。借助所提供的 onTrimMemory() 回调方法
import android.content.ComponentCallbacks2;
// Other import statements ...
public class MainActivity extends AppCompatActivity
implements ComponentCallbacks2 {
//当UI隐藏或系统资源不足时释放内存。
public void onTrimMemory(int level) {
switch (level) {
case ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN:
//释放当前持有内存的所有UI对象。用户界面已经移动到后台。
break;
case ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE:
case ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW:
case ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL:
//释放应用程序不需要运行的内存。应用程序运行时,设备内存不足。引发的事件表示与内存相关的事件的严重程度。如果事件是TRIM_MEMORY_RUNNING_CRITICAL,那么系统将开始杀死后台进程。
break;
case ComponentCallbacks2.TRIM_MEMORY_BACKGROUND:
case ComponentCallbacks2.TRIM_MEMORY_MODERATE:
case ComponentCallbacks2.TRIM_MEMORY_COMPLETE:
// 释放进程能释放的尽可能多的内存。该应用程序在LRU列表中,系统内存不足。引发的事件表明该应用程序在LRU列表中的位置。如果事件是TRIM_MEMORY_COMPLETE,则该进程将是第一个被终止的。
break;
default:
//应用程序收到一个无法识别的内存级别值从系统。将此消息视为一般的低内存消息。
break;
}
}
}