开篇词:跳出舒适区,走在Android行业的前端

你好,我是姜新星,一个深耕 Andorid 领域的老工程师。

记得 2010 年毕业典礼上,某位老师说“你们是最幸福的一届毕业生,正好赶上中国移动互联网的元年”,虽然当时对这句话还是懵懵懂懂的状态,但还是义无反顾地投入了 Android 开发这波浪潮。“一厘米宽,一公里深”,一干就是十年。

在这期间,我先后任职于斐讯、一号药店、360、英孚教育等多家公司,参与过 Launcher 定制化、ROM 定制、在线教育、电商等众多领域的开发工作,算是见证了国内智能手机厮杀的前世今生,也对 Android 开发有了一些独到的见解。

面试,是技术深度与广度的试金石

如今在英孚教育,因为工作需要,我经常需要面试一些 Android 工程师,过程中发现很多人对于 Android 知识的掌握还是多浮于表面,对一些技术点只停留在“使用过”甚至是“听说过”的阶段,这其中甚至不乏一些工作 5 年以上的 Android 工程师。

  • 几乎所有的 Android 开发者都使用过 String,但当我问“String 的最大长度是多少”时,鲜有人能够完全答对,更何谈如何去解决“字符串长度过长”的异常问题。

  • 有人会很费解,为什么面试官会问到如何自定义 Java 类加载器的问题,难道双亲委派机制不是 JVM 内部实现的吗?但殊不知,不理解 JVM 的底层原理,就谈不上自定义类加载器,更不用说实现热修复、组件化开发了。

  • 还有一些面试者,会在面试前恶补 JVM 知识,对 Java垃圾回收(GC)也能够侃侃而谈,甚至还能够现场说出一些 JVM 调优参数。但是,如果你再展开提问“Android 中 Dalvik 和 ART 的回收机制有何不同”,可能就瞬间“宕机”了。

  • 很多人都会在简历中标识自己“精通多线程”,但是扪心自问一下这几个问题能答上来吗:线程中的“工作内存”指的是什么? 为什么会导致线程安全问题?

当然,也有很多求职者抱怨大厂“面试造火箭,工作拧螺丝”,但实际上,面试中的大多数问题都是在全方位地考察你对技术的理解深度,以及解决问题的能力。你看似无理甚至无用的问题,比如“是否写过自定义 Gradle 插件?”,其实是面试官想借此看看你对 Gradle 的理解情况和掌握程度,进而引申到你对 Android 编译打包流程的理解。在熟知编译打包流程的前提下,你才会深谙 APK 深度瘦身的原理和过程。

听起来像是各种套路,但是技术是无止境的,你需要对自己提交的每一行代码、使用的每一个工具负责,不断挖掘其底层原理,才能使自己的技术升华到更高的层面。

如今,国内移动互联网的红利期已经过去,Android 开发工程师也从最初的一人难求,到现在的一个岗位百人竞争,僧多粥少的情况直接导致整个行业对求职者的要求越来越高。可以说,初中级工程师基本已无立足之地,即使高级工程师也经常在面试中碰钉子。

此外,随着 Android 开发越来越规范,国内工程师的素质,以及用户对产品的要求也越来越高。这也间接导致我们对研发项目的质量要求到了近乎苛刻的地步,内存优化、UI 卡顿优化、App 崩溃监控等性能调优也逐渐成了人手必备的技能。工作之余,难免让我们感慨学无止境,以及 Android 开发也是水深不见底。

跳出舒适区,走在行业前端

我的第一份工作是在斐讯这家专攻通信设备的公司,我当时在智能手机研发终端部门,负责 Android 2.2 系统中 Launcher2 的定制化。随后 Android 系统不断升级,从 2.X 系统搭配 Marvel 芯片到 4.X 系统搭配高通芯片,从 Launcher2 到 Launcher3,再到后来的 Framework,这让我对 Android 系统底层源码有了深入的理解,也为后来掌握整个 Android 生态知识体系打下了坚实的基础。

后来,国内移动互联网行业如雨后春笋般发展,Android App 的开发需求也越来越多。因为ROM定制化开发还是有一定的局限性,为了拓宽自己的职业发展方向,以及对互联网 App 开发的一丝好奇,让我萌生了从厂商出来看看世界的想法。

刚进入 App 开发领域,我发现和系统源码开发完全不一样,互联网 App 开发需要掌握各种架构和第三方框架,比如 MVP、MVVM、Volley、Picasso、GreenDao 等。而这些开源库框架的实现或者理念,对于那时的我来说完全是新事物。意识到自己的不足之后,我开始疯狂恶补各种开源库的实现原理,比较各个"轮子"之间的优缺点,同时也尝试自己去写开源控件(一篇往期文章,参见“自定义ViewGroup实现仿淘宝的商品详情页”)。

这个过程,让我慢慢发现了自己的一些优势。在与同事吃饭聊天时,每当聊到某一个 Bug 产生的原因时,我总能从 Framework 层源码的角度分析根本原因,并指出客户端需要在哪些方面做出适配,这对只做过 App 开发的工程师来说是一个比较大的瓶颈。比如,我在网上分享过如何在 Activity 中使用 Theme.AppCompat 主题(You need to use a Theme.AppCompat theme with this activity) 的相关解决思路,网上大多对这个问题的解决办法都是让 MainActivity 继承自 Activity 即可,但是这种解决思路有极大的局限性,且不说改为 Activity 是否适合每家公司的项目,即使免为其难全部改为 Activity,也会导致无法兼容老版本的样式,并且相当于间接放弃了 Android 5.0 之后的 Material Design 效果。因此只有了解源码,并从源码的角度分析,才会找到更完美的解决方案

记得是在一次代表公司参加上海开发者讨论会的时候,我听到了很多之前不曾太关注的名词,比如组件化开发、性能监控、Android 演进式架构等等,仿佛打开了我通往 Android 世界的另一扇大门,原来 Android 开发并不是单纯的实现各种业务逻辑,而是一整套架构的搭建,比如自动化测试、性能监控、组件解耦等。回到公司之后,我与同事讨论的话题不再是某某公司新开发的框架,而是变成了“App 的崩溃率是多少?”“埋点怎么做?”“架构中基础组件之间通信是怎么玩的?”等等。

而当我尝试去搭建这一套基础架构时,发现自己虽然做了多年的 Android 开发,但是对于 Java 虚拟机、Dalvik 字节码的理解远远不够,所以在实现性能监控、组件化/插件化、编译插桩时举步维艰,深深体会到了技能瓶颈的限制。幸好公司给予我足够的信任和包容,我开始深挖 Java 虚拟机、Linux 系统、Android 操作系统等各种知识,尝试新的玩法,最终成功搭建了一套自己的性能检测流程、线上监控系统,以及完整的无痕埋点实现机制。

课程设计

鉴于以上原因,我一直在寻找一个可以分享过往经验的出口。

在这个专栏中,我希望为你理清 Android 面试的主线思路,通过详解各大互联网公司的常见面试题,从面试的角度去展开介绍某一知识点,以及该知识点在项目中的使用,并在此过程中帮你梳理和建立 Android 开发的知识体系。

因此,无论是你短期内想提升 Android 内功实力,突破自己工作中的能力瓶颈,还是准备参加 Android 面试,都会在这个课程中有所收获。

这个课程,我根据面试时经常被问到的几个方向,划分了 4 个模块来展开:

  • JVM 必知必会:通过介绍 JVM 和 DVM ,使你对 Java 字节码与 Dalvik 字节码的执行机制有一定的理解。

  • Android 核心术:介绍 Android 开发中常用的核心技术,比如自定义 View、Handler,以及一些开源框架的原理实现。

  • 源码分析通过剖析部分 Android Framework 源码,使你对 Activity 启动、APK 安装过程等流程了然于胸。

  • 常见问题剖析:介绍一些项目中常见的疑难问题,使你能够对现有项目做出合理并迅速的重构优化。

为便于你理解,我会采用“知识点 + 项目实践”的讲解方式,侧重总结工作上的实践经验,并和你分享一些疑难问题的解决思路,让你在以后的工作中,能够有方法论的指导。

此外,技术内容终归过于抽象,为了方便你更直观、方便地理解课程内容,我会借助大量图示来解释某一原理或者工作流程,并在专栏中穿插大量诸如 ASMDemo 、DexClassLoaderHotFix 等代码案例,来一步步告诉你如何将学到的底层原理应用到实际项目中。

写在最后

Android 工程师的竞争环境越来越激烈,但 Andorid 开发仍然是一个刚需的工种,行业对人才的需求从未终止,但与此同时也对我们提出了更高的要求。如果你仍然只是在日常开发过程中复制粘贴,或者仍以完成一个项目需求为唯一目的,只是掌握了如何去调用某个 API 或者数据结构来实现某个功能,已经不能算是合格的工程师了。

对于 Android 开发者来说,要成为优秀的 Android 开发工程师,不只能够让你在工作中更加游刃有余,同样会让你在职业发展中面临更多优质的机遇。而一个初中级 Android 工程师在通往高级甚至是资深工程师的发展过程中,我认为这个课程中超过 90% 的内容都是你必须掌握,且无法绕过的。

挑战中,总是蕴藏着机遇;大浪淘沙,留下来的必定都是顶尖人才。希望我能够在这里帮到你。当然,技术首先在于学习和理解,更关键地在于后续的实践应用,也希望你能将课程所学不断应用和反馈到你的工作实践之中,并欢迎和我分享你的成长。


第01讲:程序运行时,Java内存到底是如何进行分配的?

本课时开始我们正式进入 Android 专栏课的学习。

之前有很多人将 Java 的内存分为堆内存(heap)和栈内存(Stack),这种划分方式在一定程度上体现了这两块区域是 Java 工程师最关注的内存区域。但是其实这种划分方式并不完全准确。

Java 的内存区域划分实际上远比这复杂:Java 虚拟机在执行 Java 程序的过程中,会把它所管理的内存划分为不同的数据区域。下面这张图描述了一个 HelloWorld.java 文件被 JVM 加载到内存中的过程:

  1. HelloWorld.java 文件首先需要经过编译器编译,生成 HelloWorld.class 字节码文件。

  2. Java 程序中访问HelloWorld这个类时,需要通过 ClassLoader(类加载器)将HelloWorld.class 加载到 JVM 的内存中。

  3. JVM 中的内存可以划分为若干个不同的数据区域,主要分为:程序计数器、虚拟机栈、本地方法栈、堆、方法区

1.1 程序计数器(Program Counter Register)

Java 程序是多线程的,CPU 可以在多个线程中分配执行时间片段。当某一个线程被 CPU 挂起时,需要记录代码已经执行到的位置,方便 CPU 重新执行此线程时,知道从哪行指令开始执行。这就是程序计数器的作用。

“程序计数器”是虚拟机中一块较小的内存空间,主要用于记录当前线程执行的位置。

如下图所示:每个线程都会记录一个当前方法执行到的位置,当 CPU 切换回某一个线程上时,则根据程序计数器记录的数字,继续向下执行指令。

实际上除了上图演示的恢复线程操作之外,其它一些我们熟悉的分支操作、循环操作、跳转、异常处理等也都需要依赖这个计数器来完成。

关于程序计数器还有几点需要格外注意:

  1. 在 Java 虚拟机规范中,对程序计数器这一区域没有规定任何 OutOfMemoryError 情况(或许是感觉没有必要吧)。

  2. 线程私有的,每条线程内部都有一个私有程序计数器。它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

  3. 当一个线程正在执行一个 Java 方法的时候,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。

1.2 虚拟机栈

虚拟机栈也是线程私有的,与线程的生命周期同步。在 Java 虚拟机规范中,对这个区域规定了两种异常状况:

  1. StackOverflowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出。

  2. OutOfMemoryError:当 Java 虚拟机动态扩展到无法申请足够内存时抛出。

在我们学习 Java 虚拟机的的过程当中,经常会看到一句话:

JVM 是基于栈的解释器执行的,DVM 是基于寄存器解释器执行的。

上面这句话里的“基于栈”指的就是虚拟机栈。虚拟机栈的初衷是用来描述 Java 方法执行的内存模型,每个方法被执行的时候,JVM 都会在虚拟机栈中创建一个栈帧,接下来看下这个栈帧是什么。

栈帧

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,每一个线程在执行某个方法时,都会为这个方法创建一个栈帧。

我们可以这样理解:一个线程包含多个栈帧,而每个栈帧内部包含局部变量表操作数栈动态连接返回地址等。如下图所示:

局部变量表

局部变量表是变量值的存储空间,我们调用方法时传递的参数,以及在方法内部创建的局部变量都保存在局部变量表中。在 Java 编译成 class 文件的时候,就会在方法的 Code 属性表中的 max_locals 数据项中,确定该方法需要分配的最大局部变量表的容量。如下代码所示:

public static int add(int k) {int i = 1;int j = 2;return i + j + k;
}

使用 javap -v 反编译之后,得到如下字节码指令:

public static int add(int);descriptor: (I)Iflags: ACC_PUBLIC, ACC_STATICCode:stack=2, locals=3, args_size=10: iconst_11: istore_12: iconst_23: istore_24: iload_15: iload_26: iadd7: iload_08: iadd9: ireturn

上面的 locals=3 就是代表局部变量表长度是 3,也就是说经过编译之后,局部变量表的长度已经确定为3,分别保存:参数 k 和局部变量 i、j。

注意:系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值),也就是说不存在类变量那样的准备阶段。这一点会在后续的 Class 初始化课时详细介绍。

操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO)。

同局部变量表一样,操作数栈的最大深度也在编译的时候写入方法的Code属性表中的max_stacks数据项中。栈中的元素可以是任意Java数据类型,包括long和double。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的。在方法执行的过程中,会有各种字节码指令被压入和弹出操作数栈(比如:iadd指令就是将操作数栈中栈顶的两个元素弹出,执行加法运算,并将结果重新压回到操作数栈中)。

动态链接

动态链接的主要目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。

在一个 class 文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其所在内存地址中的直接引用,而符号引用存在于方法区中。

Java 虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的就是为了支持方法调用过程中的动态连接(Dynamic Linking)。具体过程会在后续的字节码执行课时中介绍。

返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:

  • 正常退出:指方法中的代码正常完成,或者遇到任意一个方法返回的字节码指令(如return)并退出,没有抛出任何异常。

  • 异常退出:指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

无论当前方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行。而虚拟机栈中的“返回地址”就是用来帮助当前方法恢复它的上层方法执行状态。

一般来说,方法正常退出时,调用者的 PC 计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

实例讲解

我用一个简单的 add() 方法来演示, 代码如下:

public int add() {int i = 1;int j = 2;int result = i + j;return result + 10;
}

我们经常会使用 javap 命令来查看某个类的字节码指令,比如 add() 方法的代码,经过 javap 之后的字节码指令如下:

0: iconst_1    (把常量 1 压入操作数栈栈顶)
1: istore_1    (把操作数栈栈顶的出栈放入局部变量表索引为 1 的位置)
2: iconst_2    (把常量 2 压入操作数栈栈顶)
3: istore_2    (把操作数栈栈顶的出栈放入局部变量表索引为 2 的位置)
4: iload_1     (把局部变量表索引为 1 的值放入操作数栈栈顶)
5: iload_2     (把局部变量表索引为 2 的值放入操作数栈栈顶)
6: iadd        (将操作数栈栈顶的和栈顶下面的一个进行加法运算后放入栈顶)
7: istore_3    (把操作数栈栈顶的出栈放入局部变量表索引为 3 的位置)
8: iload_3     (把局部变量表索引为 3 的值放入操作数栈栈顶)
9: bipush 10   (把常量 10 压入操作数栈栈顶)
11: iadd       (将操作数栈栈顶的和栈顶下面的一个进行加法运算后放入栈顶)
12: ireturn    (结束)

从上面字节码指令也可以看到,其实局部变量表和操作数栈在代码执行期间是协同合作来达到某一运算效果的。接下来通过图示来看下这几行代码执行期间,虚拟机栈的实际情况。

首先说一下各个指令代表什么意思:

  • iconst 和 bipush,这两个指令都是将常量压入操作数栈顶,区别就是:当 int 取值 -1~5 采用 iconst 指令,取值 -128~127 采用 bipush 指令。

  • istore 将操作数栈顶的元素放入局部变量表的某索引位置,比如 istore_5 代表将操作数栈顶元素放入局部变量表下标为 5 的位置。

  • iload 将局部变量表中某下标上的值加载到操作数栈顶中,比如 iload_2 代表将局部变量表索引为 2 上的值压入操作数栈顶。

  • iadd 代表加法运算,具体是将操作数栈最上方的两个元素进行相加操作,然后将结果重新压入栈顶。

首先在 Add.java 被编译成 Add.class 的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到了方法表的 Code 属性中。因此这会局部变量表的大小是确定的,add() 方法中有 3 个局部变量,因此局部变量表的大小为 3,但是操作数栈此时为空。

所以代码刚执行到 add 方法时,局部变量表和操作数栈的情况如下:

icons_1  把常量 1 压入操作数栈顶,结果如下:

istore_1 把操作数栈顶的元素出栈并放入局部变量表下标为 1 的位置,结果如下:

可以看出此时操作数栈重新变为空,并将出栈的元素 1 保存在局部变量表中。

iconst_2 把常量 2 压入操作数栈顶,结果如下:

istore_2 把操作数栈顶的元素出栈并放入局部变量表下标为 2 的位置,结果如下:

接下来是两步 iload 操作,分别是 iload_1 和 iload_2。分别代表的是将局部变量表中下标为 1 和下标为 2 的元素重新压入操作数栈中,结果如下:

接下来进行 iadd 操作,这个操作会将栈顶最上方的两个元素(也就是 1、2)进行加法操作,然后将结果重新压入到栈顶,执行完之后的结果如下:

istor_3 将操作数栈顶的元素出栈,并保存在局部变量表下标为 3 的位置。结果如下:

iload_3 将局部变量表中下标为 3 的元素重新压入到操作数栈顶,结果如下:

bipush 10 将常量 10 压入到操作数栈中,结果如下:

再次执行 iadd 操作,注意此时栈顶最上方的两个元素为 3 和 10,所以执行完结果如下:

最后执行 return 指令,将操作数栈顶的元素 13 返回给上层方法。至此 add() 方法执行完毕。局部变量表和操作数栈也会相继被销毁。

1.3 本地方法栈

本地方法栈和上面介绍的虚拟栈基本相同,只不过是针对本地(native)方法。在开发中如果涉及 JNI 可能接触本地方法栈多一些,在有些虚拟机的实现中已经将两个合二为一了(比如HotSpot)。

1.4 堆

Java 堆(Heap)是 JVM 所管理的内存中最大的一块,该区域唯一目的就是存放对象实例,几乎所有对象的实例都在堆里面分配,因此它也是 Java 垃圾收集器(GC)管理的主要区域,有时候也叫作“GC 堆”(关于堆的 GC 回收机制将会在后续课时中做详细介绍)。同时它也是所有线程共享的内存区域,因此被分配在此区域的对象如果被多个线程访问的话,需要考虑线程安全问题。

按照对象存储时间的不同,堆中的内存可以划分为新生代(Young)和老年代(Old),其中新生代又被划分为 Eden 和 Survivor 区。具体如下图所示:

图中不同的区域存放具有不同生命周期的对象。这样可以根据不同的区域使用不同的垃圾回收算法,从而更具有针对性,进而提高垃圾回收效率。

1.5 方法区

方法区(Method Area)也是 JVM 规范里规定的一块运行时数据区。方法区主要是存储已经被 JVM 加载的类信息(版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码和数据。该区域同堆一样,也是被各个线程共享的内存区域。

注意:关于方法区,很多开发者会将其跟“永久区”混淆。所以我在这里对这两个概念进行一下对比:

  • 方法区是 JVM 规范中规定的一块区域,但是并不是实际实现,切忌将规范跟实现混为一谈,不同的 JVM 厂商可以有不同版本的“方法区”的实现。

  • HotSpot 在 JDK 1.7 以前使用“永久区”(或者叫 Perm 区)来实现方法区,在 JDK 1.8 之后“永久区”就已经被移除了,取而代之的是一个叫作“元空间(metaspace)”的实现方式。

总结一下就是

  • 方法区是规范层面的东西,规定了这一个区域要存放哪些数据。

  • 永久区或者是 metaspace 是对方法区的不同实现,是实现层面的东西。

1.6 异常再现

StackOverflowError 栈溢出异常

递归调用是造成StackOverflowError的一个常见场景,比如以下代码:

在method方法中,递归调用了自身,并且没有设置递归结束条件。运行上述代码时,则会产生StackOverflowError。

原因就是每调用一次method方法时,都会在虚拟机栈中创建出一个栈帧。因为是递归调用,method方法并不会退出,也不会将栈帧销毁,所以必然会导致StackOverflowError。因此当需要使用递归时,需要格外谨慎。

OutOfMemoryError 内存溢出异常

理论上,虚拟机栈、堆、方法区都有发生OutOfMemoryError的可能。但是实际项目中,大多发生于堆当中。比如以下代码:

在一个无限循环中,动态的向ArrayList中添加新的HeapError对象。这会不断的占用堆中的内存,当堆内存不够时,必然会产生OutOfMemoryError,也就是内存溢出异常。

上图中的Xms和Xmx是虚拟机运行参数,将会在下一节垃圾回收中详细介绍。

总结

对于 JVM 运行时内存布局,我们需要始终记住一点:上面介绍的这 5 块内容都是在 Java 虚拟机规范中定义的规则,这些规则只是描述了各个区域是负责做什么事情、存储什么样的数据、如何处理异常、是否允许线程间共享等。千万不要将它们理解为虚拟机的“具体实现”,虚拟机的具体实现有很多,比如 Sun 公司的 HotSpot、JRocket、IBM J9、以及我们非常熟悉的 Android Dalvik 和 ART 等。这些具体实现在符合上面 5 种运行时数据区的前提下,又各自有不同的实现方式。

最后我们借助一张图来概括一下本课时所介绍的内容:

总结来说,JVM 的运行时内存结构中一共有两个“栈”和一个“堆”,分别是:Java 虚拟机栈和本地方法栈,以及“GC堆”和方法区。除此之外还有一个程序计数器,但是我们开发者几乎不会用到这一部分,所以并不是重点学习内容。 JVM 内存中只有堆和方法区是线程共享的数据区域,其它区域都是线程私有的。并且程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。


第02讲:GC 回收机制与分代回收策略

本课时我们讲解 GC 回收机制与分代回收策略。

垃圾回收(Garbage Collection,简写为 GC)可能是虚拟机众多知识点中最为大众所熟知的一个了,也是Java开发者最关注的一块知识点。Java 语言开发者比 C 语言开发者幸福的地方就在于,我们不需要手动释放对象的内存,JVM 中的垃圾回收器(Garbage Collector)会为我们自动回收。但是这种幸福是有代价的:一旦这种自动化机制出错,我们又不得不去深入理解 GC 回收机制,甚至需要对这些“自动化”的技术实施必要的监控和调节。

上一节课我介绍了 Java 内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作,这几个区域内不需要过多考虑回收的问题。

而堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。

什么是垃圾

所谓垃圾就是内存中已经没有用的对象。 既然是”垃圾回收",那就必须知道哪些对象是垃圾。Java 虚拟机中使用一种叫作"**可达性分析”**的算法来决定对象是否可以被回收。

可达性分析

可达性分析算法是从离散数学中的图论引入的,JVM 把内存中所有的对象之间的引用关系看作一张图,通过一组名为”GC Root"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,最后通过判断对象的引用链是否可达来决定对象是否可以被回收。如下图所示:

比如上图中,对象A/B/C/D/E 与 GC Root 之间都存在一条直接或者间接的引用链,这也代表它们与 GC Root 之间是可达的,因此它们是不能被 GC 回收掉的。而对象M和K虽然被对J 引用到,但是并不存在一条引用链连接它们与 GC Root,所以当 GC 进行垃圾回收时,只要遍历到 J/K/M 这 3 个对象,就会将它们回收。

注意:上图中圆形图标虽然标记的是对象,但实际上代表的是此对象在内存中的引用。包括 GC Root 也是一组引用而并非对象。

GC Root 对象

在 Java 中,有以下几种对象可以作为 GC Root:

  1. Java 虚拟机栈(局部变量表)中的引用的对象。
  2. 方法区中静态引用指向的对象。
  3. 仍处于存活状态中的线程对象。
  4. Native 方法中 JNI 引用的对象。

什么时候回收

不同的虚拟机实现有着不同的 GC 实现机制,但是一般情况下每一种 GC 实现都会在以下两种情况下触发垃圾回收。

  1. Allocation Failure:在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败,这时系统会触发一次 GC。
  2. System.gc():在应用层,Java 开发工程师可以主动调用此 API 来请求一次 GC。

代码验证 GC Root 的几种情况

现在我们了解了 Java 中的 GC Root,以及何时触发 GC,接下来就通过几个案例来验证 GC Root 的情况。在看具体代码之前,我们先了解一个执行 Java 命令时的参数。

-Xms 初始分配 JVM 运行时的内存大小,如果不指定默认为物理内存的 1/64。

比如我们运行如下命令执行 HelloWorld 程序,从物理内存中分配出 200M 空间分配给 JVM 内存。

java -Xms200m HelloWorld

验证虚拟机栈(栈帧中的局部变量)中引用的对象作为 GC Root

我们运行如下代码:

public class GCRootLocalVariable {private int _10MB = 10 * 1024 * 1024;private byte[] memory = new byte[8 * _10MB];

public static void main(String[] args){
        System.out.println(“开始时:”);
        printMemory();
        method();
        System.gc();
        System.out.println(“第二次GC完成”);
        printMemory();
    }

public static void method() {
        GCRootLocalVariable g = new GCRootLocalVariable();
        System.gc();
        System.out.println(“第一次GC完成”);
        printMemory();
    }

/**
      打印出当前JVM剩余空间和总的空间大小
     
/
    public static void printMemory() {
        System.out.print("free is “ + Runtime.getRuntime().freeMemory()/1024/1024 + ” M, ");
        System.out.println("total is “ + Runtime.getRuntime().totalMemory()/1024/1024 + ” M, ");
    }
}

打印日志:

开始时:
free is 242 M, total is 245 M,
第一次GC完成
free is 163 M, total is 245 M,
第二次GC完成
free is 243 M, total is 245 M,

可以看出:

  • 当第一次 GC 时,g 作为局部变量,引用了 new 出的对象(80M),并且它作为 GC Roots,在 GC 后并不会被 GC 回收。
  • 当第二次 GC:method() 方法执行完后,局部变量 g 跟随方法消失,不再有引用类型指向该 80M 对象,所以第二次 GC 后此 80M 也会被回收。

注意:上面日志包括后面的实例中,因为有中间变量,所以会有 1M 左右的误差,但不影响我们分析 GC 过程。

验证方法区中的静态变量引用的对象作为 GC Root

我们运行如下代码:

public class GCRootStaticVariable{private static int _10MB = 10 * 1024 * 1024;private byte[] memory;private static GCRootStaticVariable staticVariable;

public GCRootStaticVariable(int size) {
        memory = new byte[size];
    }

public static void main(String[] args){
        System.out.println(“程序开始:”);
        printMemory();
        GCRootStaticVariable g = new GCRootStaticVariable(4  _10MB);
        g.staticVariable = new GCRootStaticVariable(8 
 _10MB);
        // 将g置为null, 调用GC时可以回收此对象内存
        g = null;
        System.gc();
        System.out.println(“GC完成”);
        printMemory();
    }

/**
      打印出当前JVM剩余空间和总的空间大小
     
/
    public static void printMemory() {
        System.out.print("free is “ + Runtime.getRuntime().freeMemory()/1024/1024 + ” M, ");
        System.out.println("total is “ + Runtime.getRuntime().totalMemory()/1024/1024 + ” M, ");
    }
}

打印日志:

程序开始:
free is 242 M, total is 245 M,
GC完成
free is 163 M, total is 245 M,

可以看出:

程序刚开始运行时内存为 242M,并分别创建了 g 对象(40M),同时也初始化 g 对象内部的静态变量 staticVariable 对象(80M)。当调用 GC 时,只有 g 对象的 40M 被 GC 回收掉,而静态变量 staticVariable 作为 GC Root,它引用的 80M 并不会被回收。

验证活跃线程作为 GC Root

我们运行如下代码:

public class GCRootThread{

private int _10MB = 10  1024  1024;
    private byte[] memory = new byte[8 * _10MB];

public static void main(String[] args) throws Exception {
        System.out.println(“开始前内存情况:”);
        printMemory();
        AsyncTask at = new AsyncTask(new GCRootThread());
        Thread thread = new Thread(at);
        thread.start();
        System.gc();
        System.out.println(“main方法执行完毕,完成GC”);
        printMemory();

thread.join();
        at = null;
        System.gc();
        System.out.println(“线程代码执行完毕,完成GC”);
        printMemory();
    }

/**
      打印出当前JVM剩余空间和总的空间大小
     
/
    public static void printMemory() {
        System.out.print("free is “ + Runtime.getRuntime().freeMemory()/1024/1024 + ” M, ");
        System.out.println("total is “ + Runtime.getRuntime().totalMemory()/1024/1024 + ” M, ");
    }

private static class AsyncTask implements Runnable {
        private GCRootThread gcRootThread;

public AsyncTask(GCRootThread gcRootThread){
            this.gcRootThread = gcRootThread;
        }

@Override
        public void run() {
            try{
                Thread.sleep(500);
            } catch(Exception e){}
        }
    }
}

打印日志:

开始前内存情况:
free is 242 M, total is 245 M,
main方法执行完毕,完成GC
free is 163 M, total is 245 M,
线程代码执行完毕,完成GC
free is 243 M, total is 245 M,

可以看出:

程序刚开始时是 242M 内存,当调用第一次 GC 时线程并没有执行结束,并且它作为 GC Root,所以它所引用的 80M 内存并不会被 GC 回收掉。 thread.join() 保证线程结束再调用后续代码,所以当调用第二次 GC 时,线程已经执行完毕并被置为 null,这时线程已经被销毁,所以之前它所引用的 80M 此时会被 GC 回收掉。

测试成员变量是否可作为 GC Root

我们运行如下代码:

public class GCRootClassVariable{

private static int _10MB = 10  1024  1024;
    private byte[] memory;
    private GCRootClassVariable classVariable;

public GCRootClassVariable(int size){
        memory = new byte[size];
    }

public static void main(String[] args){
        System.out.println(“程序开始:”);
        printMemory();
        GCRootClassVariable g = new GCRootClassVariable(4  _10MB);
        g.classVariable = new GCRootClassVariable(8 
 _10MB);
        g = null;
        System.gc();
        System.out.println(“GC完成”);
        printMemory();
    }

/**
      打印出当前JVM剩余空间和总的空间大小
     
/
    public static void printMemory() {
        System.out.print("free is “ + Runtime.getRuntime().freeMemory()/1024/1024 + ” M, ");
        System.out.println("total is “ + Runtime.getRuntime().totalMemory()/1024/1024 + ” M, ");
    }
}

打印日志:

程序开始:
free is 242 M, total is 245 M,
GC完成
free is 243 M, total is 245 M,

从上面日志中可以看出当调用 GC 时,因为 g 已经置为 null,因此 g 中的全局变量 classVariable 此时也不再被 GC Root 所引用。所以最后 g(40M) 和 classVariable(80M) 都会被回收掉。这也表明全局变量同静态变量不同,它不会被当作 GC Root

上面演示的这几种情况往往也是内存泄漏发生的场景,设想一下我们将各个 Test 类换成 Android 中的 Activity 的话将导致 Activity 无法被系统回收,而一个 Activity 中的数据往往是较大的,因此内存泄漏导致 Activity 无法回收还是比较致命的。

如何回收垃圾

由于垃圾收集算法的实现涉及大量的程序细节,各家虚拟机厂商对其实现细节各不相同,因此本课时并不会过多的讨论算法的实现,只是介绍几种算法的思想以及优缺点。

标记清除算法(Mark and Sweep GC)

从”GC Roots”集合开始,将内存整个遍历一次,保留所有可以被 GC Roots 直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收,过程分两步。

  1. Mark 标记阶段:找到内存中的所有 GC Root 对象,只要是和 GC Root 对象直接或者间接相连则标记为灰色(也就是存活对象),否则标记为黑色(也就是垃圾对象)。
  2. Sweep 清除阶段:当遍历完所有的 GC Root 之后,则将标记为垃圾的对象直接清除。

如下图所示:

  • 优点:实现简单,不需要将对象进行移动。
  • 缺点:这个算法需要中断进程内其他组件的执行(stop the world),并且可能产生内存碎片,提高了垃圾回收的频率。

复制算法(Copying)

将现有的内存空间分为两快,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中。之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

  1. 复制算法之前,内存分为 A/B 两块,并且当前只使用内存 A,内存的状况如下图所示:

  1. 标记完之后,所有可达对象都被按次序复制到内存 B 中,并设置 B 为当前使用中的内存。内存状况如下图所示:

  • 优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
  • 缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

标记-压缩算法 (Mark-Compact)

需要先从根节点开始对所有可达对象做一次标记,之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。最后,清理边界外所有的空间。因此标记压缩也分两步完成:

  1. Mark 标记阶段:找到内存中的所有 GC Root 对象,只要是和 GC Root 对象直接或者间接相连则标记为灰色(也就是存活对象),否则标记为黑色(也就是垃圾对象)。
  2. Compact 压缩阶段:将剩余存活对象按顺序压缩到内存的某一端。
  • 优点:这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
  • 缺点:所谓压缩操作,仍需要进行局部对象移动,所以一定程度上还是降低了效率。

JVM分代回收策略

Java 虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代老年代,这就是 JVM 的内存分代策略。注意: 在 HotSpot 中除了新生代和老年代,还有永久代

分代回收的中心思想就是:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下来,则将它们转移到老年代中。

年轻代(Young Generation)

新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收 70%~95% 的空间,回收效率很高。新生代中因为要进行一些复制操作,所以一般采用的 GC 回收算法是复制算法。

新生代又可以继续细分为 3 部分:Eden、Survivor0(简称 S0)、Survivor1(简称S1)。这 3 部分按照 8:1:1 的比例来划分新生代。这 3 块区域的内存分配过程如下:

绝大多数刚刚被创建的对象会存放在 Eden 区。如图所示:

当 Eden 区第一次满的时候,会进行垃圾回收。首先将 Eden区的垃圾对象回收清除,并将存活的对象复制到 S0,此时 S1是空的。如图所示:

下一次 Eden 区满时,再执行一次垃圾回收。此次会将 EdenS0区中所有垃圾对象清除,并将存活对象复制到 S1,此时 S0变为空。如图所示:

如此反复在 S0S1之间切换几次(默认 15 次)之后,如果还有存活对象。说明这些对象的生命周期较长,则将它们转移到老年代中。如图所示:

年老代(Old Generation)

一个对象如果在新生代存活了足够长的时间而没有被清理掉,则会被复制到老年代。老年代的内存大小一般比新生代大,能存放更多的对象。如果对象比较大(比如长字符串或者大数组),并且新生代的剩余空间不足,则这个大对象会直接被分配到老年代上。

我们可以使用 -XX:PretenureSizeThreshold 来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。老年代因为对象的生命周期较长,不需要过多的复制操作,所以一般采用标记压缩的回收算法。

注意:对于老年代可能存在这么一种情况,老年代中的对象有时候会引用到新生代对象。这时如果要执行新生代 GC,则可能需要查询整个老年代上可能存在引用新生代的情况,这显然是低效的。所以,老年代中维护了一个 512 byte 的 card table,所有老年代对象引用新生代对象的信息都记录在这里。每当新生代发生 GC 时,只需要检查这个 card table 即可,大大提高了性能。

GC Log 分析

为了让上层应用开发人员更加方便的调试 Java 程序,JVM 提供了相应的 GC 日志。在 GC 执行垃圾回收事件的过程中,会有各种相应的 log 被打印出来。其中新生代和老年代所打印的日志是有区别的。

  • 新生代 GC:这一区域的 GC 叫作 Minor GC。因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代 GC:发生在这一区域的 GC 也叫作 Major GC 或者 Full GC。当出现了 Major GC,经常会伴随至少一次的 Minor GC。

注意:在有些虚拟机实现中,Major GC 和 Full GC 还是有一些区别的。Major GC 只是代表回收老年代的内存,而 Full GC 则代表回收整个堆中的内存,也就是新生代 + 老年代。

接下来就通过几个案例来分析如何查看 GC Log,分析这些 GC Log 的过程中也能再加深对 JVM 分代策略的理解。

首先我们需要理解几个 Java 命令的参数:

我使用如下代码,在内存中创建 4 个 byte 类型数组来演示内存分配与 GC 的详细过程。代码如下:

/**
* VM agrs: -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
* -XX:SurvivorRatio=8
*/
public class MinorGCTest {private static final int _1MB = 1024 * 1024;

public static void testAllocation() {
        byte[] a1, a2, a3, z4;
        a1 = new byte[2  _1MB];
        a2 = new byte[2 
 _1MB];
        a3 = new byte[2  _1MB];
        a4 = new byte[1 
 _1MB];
    }

public static void main(String[] agrs) {
        testAllocation();
    }
}

通过上面的参数,可以看出堆内存总大小为 20M,其中新生代占 10M,剩下的 10M 会自动分配给老年代。执行上述代码打印日志如下:

Heap
PSYoungGen      total 9216K, used 8003K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)eden space 8192K, 97% used [0x00000007bf600000,0x00000007bfdd0ed8,0x00000007bfe00000)from space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)to   space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
ParOldGen       total 10240K, used 0K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)object space 10240K, 0% used [0x00000007bec00000,0x00000007bec00000,0x00000007bf600000)
Metaspace       used 2631K, capacity 4486K, committed 4864K, reserved 1056768Kclass space    used 286K, capacity 386K, committed 512K, reserved 1048576K

日志中的各字段代表意义如下:

从日志中可以看出:程序执行完之后,a1、a2、a3、a4 四个对象都被分配在了新生代的 Eden 区。

如果我们将测试代码中的 a4 初始化改为 a4 = new byte[2 * _1MB] 则打印日志如下:

[GC (Allocation Failure) [PSYoungGen: 6815K->480K(9216K)] 6815K->6632K(19456K), 0.0067344 secs] [Times: user=0.04 sys=0.00, real=0.01 secs]
Heap
PSYoungGen      total 9216K, used 2130K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)eden space 8192K, 26% used [0x00000007bf600000,0x00000007bf814930,0x00000007bfe00000)from space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)to   space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
ParOldGen       total 10240K, used 6420K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)object space 10240K, 62% used [0x00000007bec00000,0x00000007bf2450d0,0x00000007bf600000)
Metaspace       used 2632K, capacity 4486K, committed 4864K, reserved 1056768Kclass space    used 286K, capacity 386K, committed 512K, reserved 1048576K

这是因为在给 a4 分配内存之前,Eden 区已经被占用 6M。已经无法再分配出 2M 来存储 a4 对象。因此会执行一次 Minor GC。并尝试将存活的 a1、a2、a3 复制到 S1 区。但是 S1 区只有 1M 空间,所以没有办法存储 a1、a2、a3 任意一个对象。在这种情况下 a1、a2、a3 将被转移到老年代,最后将 a4 保存在 Eden 区。所以最终结果就是:Eden 区占用 2M(a4),老年代占用 6M(a1、a2、a3)。

通过这个测试案例,我们也间接验证了 JVM 的内存分配和分代回收策略。如果你感兴趣可以在课下尝试使用各种命令参数,给堆的新生代和老年代设置不同的大小来验证不同的结果。

再谈引用

上文中已经介绍过,判断对象是否存活我们是通过GC Roots的引用可达性来判断的。但是JVM中的引用关系并不止一种,而是有四种,根据引用强度的由强到弱,他们分别是:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。

任何一本Java面试书籍都会对这四种引用做简单对比,我用一张表格来表示如下:

平时项目中,尤其是Android项目,因为有大量的图像(Bitmap)对象,使用软引用的场景较多。所以重点看下软引用SoftReference的使用,不当的使用软引用有时也会导致系统异常。

软引用常规使用

常规使用代码如下:

执行上述代码,打印日志如下:

首先通过-Xmx将堆最大内存设置为200M。从日志中可以看出,当第一次GC时,内存中还有剩余可用内存,所以软引用并不会被GC回收。但是当我们再次创建一个120M的强引用时,JVM可用内存已经不够,所以会尝试将软引用给回收掉。

软引用隐藏问题

需要注意的是,被软引用对象关联的对象会自动被垃圾回收器回收,但是软引用对象本身也是一个对象,这些创建的软引用并不会自动被垃圾回收器回收掉。比如如下代码:

上述代码,虽然每一个SoftObject都被一个软引用所引用,在内存紧张时,GC会将SoftObject所占用的1KB回收。但是每一个SoftReference又都被Set所引用(强引用)。执行上述代码结果如下:

限制堆内存大小为4M,最终程序崩溃,但是异常的原因并不是普通的堆内存溢出,而是"GC overhead"。之所以会抛出这个错误,是由于虚拟机一直在不断回收软引用,回收进行的速度过快,占用的cpu过大(超过98%),并且每次回收掉的内存过小(小于2%),导致最终抛出了这个错误。

这里需要做优化,合适的处理方式是注册一个引用队列,每次循环之后将引用队列中出现的软引用对象从cache中移除。如下所示:

再次运行修改后的代码,结果如下:

可以看出优化后,程序可以正常执行完。并且在执行过程中会动态的将集合中的软引用删除。

更多详细 SoftReference 的介绍,可以参考 、Java虚拟机究竟是如何处理SoftReference的 。

总结:

本课时着重讲解了 JVM 中有关垃圾回收的相关知识点,其中重点介绍了使用可达性分析来判断对象是否可以被回收,以及 3 种垃圾回收算法。最后通过分析 GC Log 验证了 Java 虚拟机中内存分配及分代策略的一些细节。

虚拟机垃圾回收机制很多时候都是影响系统性能、并发能力的主要因素之一。尤其是对于从事 Android 开发的工程师来说,有时候垃圾回收会很大程度上影响 UI 线程,并造成界面卡顿现象。因此理解垃圾回收机制并学会分析 GC Log 也是一项必不可少的技能。后续我会在 DVM 课时中,详细介绍 Android 虚拟机中对垃圾回收所做的优化。


Android工程师进阶第一课 夯实Java基础 JVM内存模型和GC回收机制相关推荐

  1. Java基础 —— JVM内存模型与垃圾回收

    目录 一.概述 二.运行时数据区 方法区 运行时常量池 堆 栈 本地方法栈 程序计数器 三.对象访问 四.垃圾回收 如何定义垃圾 1.引用计数法 2.可达性分析 垃圾回收方法 1.Mark-Sweep ...

  2. 详解JVM内存管理与垃圾回收机制5 - Java中的4种引用类型

    在Java语言中,除了基础数据类型的变量以外,其他的都是引用类型,指向各种不同的对象.在前文我们也已经知道,Java中的引用可以是认为对指针的封装,这个指针中存储的值代表的是另外一块内存的起始地址(对 ...

  3. JVM原理(Java代码编译和执行的整个过程+JVM内存管理及垃圾回收机制)

    转载注明出处: http://blog.csdn.net/cutesource/article/details/5904501 JVM工作原理和特点主要是指操作系统装入JVM是通过jdk中Java.e ...

  4. java内存 海子_Java虚拟机:JVM内存模型和volatile详解

    JVM内存模型和volatile详解 Java内存模型 随着计算机的CPU的飞速发展,CPU的运算能力已经远远超出了从主内存(运行内存)中读取的数据的能力,为了解决这个问题,CPU厂商设计出了CPU内 ...

  5. jvm面试2 jvm如何加载java代码? JVM知识重点:内存模型和GC

    jvm如何加载java代码? native方法forName0 JVM知识重点:内存模型和GC' 注意:jvm是一个内存中的虚拟机 下面是Class类中,我们常用的forName方法 @CallerS ...

  6. Android工程师进阶第九课 Android优化实战

    第24讲:APK 如何做到包体积优化? 关于 APK Size 的优化,网上有很多版本的介绍.但是因为每个项目的背景.实现方式都不尽相同,导致各个项目之间能列出的共性相对较少.所以这节课我主要分享一下 ...

  7. Android开发之Java基础JVM和ClassLoader以及类加机制面试题

    在面试中被问到Java相关的东西非常多: 首先说下Java内存模型: 主要由: 程序计数器,Java虚拟机栈,本地方法栈=>被线程私有 方法区 堆组成=>被线程共有 Java类加载机制Cl ...

  8. 白话Java基础—JVM内存模型

    白话Java基础系列-Java内存模型 本系列呢,主要将我理解的Java基础内容,以比较白话的方式,解释出来,希望能帮助大家快速的理解一些概念. 本文主要内容: 为什么要有Java内存模型?什么是Ja ...

  9. Java基础——JVM内存结构

    推荐阅读:https://www.cnblogs.com/wangjzh/p/5258254.html 一.内存结构图 先导知识: 一个 Java 源程序文件,会被编译为字节码文件(以 class 为 ...

最新文章

  1. 洛谷 P1318 积水面积
  2. 按键精灵通过句柄获取窗口坐标_使用visual studio 2019 创建简单的Windows窗口「Win32」...
  3. 关于数据中心机房的专业名词
  4. 晶体封装越小esr越大_SuperFin晶体管技术加持!英特尔新一代10nm可媲美台积电5nm?...
  5. Create QR Code
  6. TreeMap源码解析
  7. CCF202104-3 DHCP服务器(100分)【模拟】
  8. iOS:主流启动优化方案浅析
  9. 1.5 万字 + 40 张图解 HTTP 常见面试题(值得收藏)
  10. C 语言学习笔记(一):C 语言的开发环境
  11. Visio自制直角箭头
  12. Win10安装注册机出错?
  13. 计算机显示器电源原理,ATX电源的工作原理
  14. 你要的所有数据源都在这里了!
  15. Android 全屏悬浮窗适配(悬浮窗沉浸式)
  16. 硅谷企业SaaS服务教父:如何衡量与优化SaaS企业的核心指标
  17. [C/C++]7-3 谷歌的招聘
  18. 华为ota鸿蒙升级,华为鸿蒙系统怎么升级?升级鸿蒙系统步骤
  19. Android弹出窗口(确定/取消)
  20. 永久免费内网穿透很简单,一看就明白(长文)

热门文章

  1. oracle执行语句出无效字符,pl/sql动态执行sql语句时报错:ORA-00911: 无效字符 ORA-06512: 在 line 14...
  2. 极客日报第 37 期:苹果官网出现价格 Bug;大众 CEO点评“苹果造车”;Spring Cloud 2020.0 正式发布
  3. 北航计算机控制系统实验报告,北航计算机控制系统实验报告教程.doc
  4. 搜狗收录怎么增加-搜狗站长推送工具
  5. DTI及MRI数据预处理
  6. 树状数组再进阶(区间修改+区间查询)
  7. 云和恩墨新晋ACED熊军微信大讲堂主题分享
  8. 优维科技低代码开发平台在券商系统的落地实践
  9. [转]手机游戏六大渠道评测标准大合集
  10. 申论公文题-总结类-1