parent
1683408505
commit
3f57e5b5ab
@ -0,0 +1,4 @@ |
|||||||
|
--- |
||||||
|
UI 优化 |
||||||
|
--- |
||||||
|
|
@ -0,0 +1,70 @@ |
|||||||
|
--- |
||||||
|
内存优化 |
||||||
|
--- |
||||||
|
|
||||||
|
#### 目录 |
||||||
|
|
||||||
|
1. 设备分级 |
||||||
|
2. Bitmap 优化 |
||||||
|
3. 内存泄露 |
||||||
|
|
||||||
|
#### 前言 |
||||||
|
|
||||||
|
内存优化,应该从哪里着手呢?通常会从设备分级、Bitmap 优化和内存泄露这三个方面入手。 |
||||||
|
|
||||||
|
#### 设备分级 |
||||||
|
|
||||||
|
内存优化首先需要根据设备环境来综合考虑。 |
||||||
|
|
||||||
|
- 设备分级 |
||||||
|
|
||||||
|
对于低端机用户可以关闭复杂的动画、或者某些功能;使用 565 格式的图片,使用更小的缓存内存等。在现实生活中,不是每个用户的设备都跟我们的测试机一样高端,在开发过程中我们要学会思考功能要不要对低端机开启、在资源紧张的时候能不能做降级。 |
||||||
|
|
||||||
|
- 缓存管理 |
||||||
|
|
||||||
|
需要有一套统一的缓存管理机制,可以适当的使用内存;可以使用 onTrimMemory 回调,根据不同的状态决定释放多少内存。对于大项目来说,可能存在几十上百个模块,统一缓存管理可以更好的监控每个模块的缓存大小。 |
||||||
|
|
||||||
|
- 进程模型 |
||||||
|
|
||||||
|
一个空进程也会占用 10M 的内存,减少应用启动的进程数,减少常驻进程、有节操的包活,对低端机内存优化非常重要。 |
||||||
|
|
||||||
|
- 安装包大小 |
||||||
|
|
||||||
|
安装包中的代码、资源、图片以及 so 库的体积,跟它们占用的内存有很大的关系。一个 80M 的应用很难在 512 MB 的内存手机上流畅的运行。这种情况我们需要考虑针对低端机用户推出 4MB 的轻量版本,例如 Facebook Lite、今日头条极速版都是这个思路。 |
||||||
|
|
||||||
|
|
||||||
|
#### Bitmap 优化 |
||||||
|
|
||||||
|
Bitmap 内存一般占应用总内存很大一部分,所以内存优化永远无法避开图片内存这个永恒主题。 |
||||||
|
|
||||||
|
即使把所有的 Bitmap 都放到 Native 内存,并不代码图片内存问题就完全解决了,这样做只是提示了系统内存利用率,减少了 GC 带来的一些问题而已。 |
||||||
|
|
||||||
|
如果优化图片内存呢?这里介绍两种方法。 |
||||||
|
|
||||||
|
##### 方法一,统一图片库 |
||||||
|
|
||||||
|
图片内存优化的前提是收拢图片的调用,这样我们可以做整体的控制策略。例如低端机使用 565 格式、更加严格的缩放算法,可以使用 Glide、Fresco 或者采取自研都可以。而且需要进一步将所有 Bitmap.createBitmap、BitmapFactory 相关的接口也一并收拢。 |
||||||
|
|
||||||
|
##### 方法二:统一监控 |
||||||
|
|
||||||
|
在统一图片库后就非常容易监控 Bitmap 的使用情况了,这里有三点需要注意: |
||||||
|
|
||||||
|
- 大图片监控 |
||||||
|
|
||||||
|
我们需要注意某张图片内存占用是否过大,例如长宽远远大于 View 甚至屏幕的长宽。在开发过程中,如果检测到不合格的图片使用,应该立即弹出对话框提示图片所在的 Activity 和堆栈,让开发同学更快的发现并解决问题。在灰度和线上环境下可以将异常信息上报到后台,我们可以计算有多少比例的图片会超过屏幕的大小,也就是图片的“超宽率”。 |
||||||
|
|
||||||
|
- 重复图片监控 |
||||||
|
|
||||||
|
重复图片指的是 Bitmap 的像素数据完全一致,但是有很多不同的对象存在。 |
||||||
|
|
||||||
|
- 图片总内存 |
||||||
|
|
||||||
|
通过收拢图片使用,我们还可以统计应用所有图片占用的内存,这样在线上就可以按不同的系统、屏幕分辨率等维度去分析图片内存的占用情况。在 OOM 崩溃的时候,也可以把图片占用的总内存、Top N 图片的内存都写到崩溃日志中,帮助我们排查问题。 |
||||||
|
|
||||||
|
#### 内存泄露 |
||||||
|
|
||||||
|
内存泄露简单来说就是没有回收不再使用的内存,排查和解决内存泄露也是内存优化无法避开的工作之一。 |
||||||
|
|
||||||
|
内存泄露主要分两种情况,一种是同一个对象泄露,还有一种情况更加糟糕,就是每次都会泄露新的对象,可能会出现几百上千个无用的对象。 |
||||||
|
|
||||||
|
对于 Java 内存泄露,可以使用 LeakCanary 自动化检测方案。 |
@ -0,0 +1,4 @@ |
|||||||
|
--- |
||||||
|
包体积优化 |
||||||
|
--- |
||||||
|
|
@ -0,0 +1,14 @@ |
|||||||
|
--- |
||||||
|
卡顿优化 |
||||||
|
--- |
||||||
|
|
||||||
|
#### 目录 |
||||||
|
|
||||||
|
#### 卡顿排查工具 |
||||||
|
|
||||||
|
Traceview 和 systrace。 |
||||||
|
|
||||||
|
#### 卡顿监控 |
||||||
|
|
||||||
|
基于 AspectJ 检测方法执行耗时。 |
||||||
|
|
@ -0,0 +1,11 @@ |
|||||||
|
--- |
||||||
|
启动优化 |
||||||
|
--- |
||||||
|
|
||||||
|
#### 目录 |
||||||
|
|
||||||
|
#### 启动分析 |
||||||
|
|
||||||
|
![](https://i.loli.net/2019/02/22/5c6f88fea0174.png) |
||||||
|
|
||||||
|
#### 冷启动、热启动 |
@ -0,0 +1,137 @@ |
|||||||
|
--- |
||||||
|
存储优化 |
||||||
|
--- |
||||||
|
|
||||||
|
#### 目录 |
||||||
|
|
||||||
|
1. Android 的存储基础 |
||||||
|
2. 常见的数据存储方法 |
||||||
|
- SharedPreferences |
||||||
|
- ContentProvider |
||||||
|
- 文件 |
||||||
|
- 数据库 |
||||||
|
3. 对象序列化 |
||||||
|
4. 数据序列化 |
||||||
|
|
||||||
|
#### Android 的存储基础 |
||||||
|
|
||||||
|
#### 常见的数据存储方法 |
||||||
|
|
||||||
|
存储就是把特定的数据结构转化成可以被记录和还原的格式,这个数据格式可以是二进制的,也可以是 XML、JSON、Protocol Buffer 这些格式。 |
||||||
|
|
||||||
|
##### SharedPreferences |
||||||
|
|
||||||
|
SharedPreferences 是 Android 中比较常见的存储方法,它可以用来存储一些比较小的键值对集合。但是它也有很多问题: |
||||||
|
|
||||||
|
1. 跨进程不安全 |
||||||
|
|
||||||
|
由于没有使用跨进程的锁,就算使用 MODE_MULTI_PROCESS,SharedPreferences 在跨进程频繁读写有可能导致数据全部丢失。SP 大约会有万分之一的损坏率。 |
||||||
|
|
||||||
|
2. 加载缓慢 |
||||||
|
|
||||||
|
SP 文件的加载使用异步线程,而且加载线程并没有设置线程优先级,如果这个时候线程读取数据需要等待文件加载线程的结束。这就导致出现主线程等待低优先级线程锁的问题,比如一个 100KB 的 SP 文件读取等待时间大约需要 50~100ms,建议提前用异步线程预加载启动过程用到的 SP 文件。 |
||||||
|
|
||||||
|
3. 全量写入 |
||||||
|
|
||||||
|
无论是调用 commit 还是 apply,即使我们只改动其中的一个条目,都会把整个内容全部写到文件。而且即使我们多次写入同一个文件,SP 也没有将多次修改合并为一次,这也是性能差的重要原因之一。 |
||||||
|
|
||||||
|
4. 卡顿 |
||||||
|
|
||||||
|
由于提供了异步落盘的 apply 机制,在崩溃或者其他一些异常情况可能会导致数据丢失。所以当应用收到系统广播,或者被调用 onPause 等一些时机,系统会强制把所有的 SP 对象数据落地到磁盘。如果没有落地成功,这时候主线程会被一直阻塞。这样非常容易造成卡顿,甚至是 ANR,从线上数据来看,SP 卡断占比一般会超过 5%。 |
||||||
|
|
||||||
|
系统提供的 SP 的应用场景是用来存储一些非常简单、轻量的数据。我们不要用它存储一些过于复杂的数据,例如 HTML、JSON 等。而且 SP 的文件存储性能与文件大小相关,每个 SP 文件不能过大,我们不要将毫无关联的配置项保存在同一个文件中,同时考虑将频繁修改的条目单独隔离出来。 |
||||||
|
|
||||||
|
我们也可以替换复写 Application 的 getSharedPreferences 方法替换系统默认实现,比如优化卡顿、合并多次 apply 操作,支持跨进程操作等。 |
||||||
|
|
||||||
|
当然,还是建议使用微信开源的 MMKV。 |
||||||
|
|
||||||
|
##### ContentProvider |
||||||
|
|
||||||
|
ContentProvider 提供了不同进程甚至不同应用程序之间共享数据的机制。 |
||||||
|
|
||||||
|
不过,使用过程中也有几点注意。 |
||||||
|
|
||||||
|
1. 启动性能 |
||||||
|
|
||||||
|
ContentProvider 的生命周期默认在 Application onCreate 之前,而且都是在主线程创建的。我们自定义的 ContentProvider 类的构造函数、静态代码块、onCreate 函数都尽量不要做耗时的操作,会拖慢启动速度。 |
||||||
|
|
||||||
|
2. 稳定性 |
||||||
|
|
||||||
|
ContentProvider 在进行跨进程数据传递时,利用了 Android 的 Binder 和匿名共享内存机制。简单来说,就是通过 Binder 传递 CursorWindow 对象内部的匿名共享文件的文件描述符。这样在跨进程传输中,结果数据不需要跨进程传输,而是在不同进程中通过传输的匿名共享内存文件描述符来操作同一块匿名内存,这样来实现不同进程访问相同数据的目的。 |
||||||
|
|
||||||
|
3. 安全性 |
||||||
|
|
||||||
|
虽然 ContentProvider 为应用程序之间的数据共享提供了很好的安全机制,但是如果 ContentProvider 是 exported,当支持执行 SQL 语句时就需要注意 SQL 注入的问题。另外如果我们传入的参数是一个文件路径,然后返回文件的内容,这个时候也要校验合法性,不然整个应用的私有数据都有可能被别人拿到。 |
||||||
|
|
||||||
|
##### SQLite |
||||||
|
|
||||||
|
这里直接推荐之前的写的文章:[SQLite](https://github.com/Omooo/Android-Notes/blob/master/blogs/Android/SQLite.md) |
||||||
|
|
||||||
|
#### 对象序列化 |
||||||
|
|
||||||
|
更宽泛的来讲,数据存储不一定就是将数据存放到磁盘中,比如放到内存中、通过网络传输也可以算是存储的一种形式。或者我们也可以把这个过程叫做对象或者数据的序列化。 |
||||||
|
|
||||||
|
对象序列化就是把一个 Object 对象所有的信息表示成一个字节序列,这包括 Class 信息、继承关系信息、访问权限、变量类型以及数值信息等。 |
||||||
|
|
||||||
|
##### Serializable |
||||||
|
|
||||||
|
Serializable 是 Java 原生的序列化机制,在 Android 中也有被广泛使用。我们可以通过 Serializable 将对象持久化存储,也可以通过 Bundle 传递 Serializable 的序列化数据。 |
||||||
|
|
||||||
|
原理: |
||||||
|
|
||||||
|
Serializable 的原理是通过 ObjectInputStream 和 ObjectOutputStream 来实现的。整个序列化过程中使用了大量的反射和临时变量,而且在序列化对象的时候,不仅会序列化当前对象本身,还需要递归序列化对象引用的其他对象。 |
||||||
|
|
||||||
|
整个过程计算非常复杂,而且因为存在大量反射和 GC 的影响,序列化的性能会比较差。另外一方面因为序列化文件需要包含的信息非常多,导致它的大小比 Class 文件本身还要大很多,这样又会导致 I/O 读写上的性能问题。 |
||||||
|
|
||||||
|
进阶使用: |
||||||
|
|
||||||
|
我们可以通过重写 writeObject 和 readObject 方法来替代默认流程,它会先反射判断是否存在我们自己实现的序列化方法 writeObject 或反序列化方法 readObject。通过这两个方法,我们可以对某些字段做一些特殊修改,也可以实现序列化的加密功能。 |
||||||
|
|
||||||
|
writeReplace 和 readResolve 方法这两个方法代理序列化的对象,可以实现自定义返回的序列化实例。那它有什么用呢?我们可以通过它们实现对象序列化的版本兼容,例如通过 readResolver 方法可以把老版本的序列化对象转化成新版本的对象类型。 |
||||||
|
|
||||||
|
注意事项: |
||||||
|
|
||||||
|
Serializable 虽然使用起来非常简单,但是也有一些需要注意的事项字段。 |
||||||
|
|
||||||
|
1. 不被序列化的字段 |
||||||
|
|
||||||
|
类的 static 变量以及被声明为 transient 的字段,默认的序列化机制都会忽略该字段,不会进行序列化存储。当然我们也可以使用进阶的 writeReplace 和 readResolve 方法做自定义的序列化存储。 |
||||||
|
|
||||||
|
2. serialVersionUID |
||||||
|
|
||||||
|
在类实现了 Serializable 接口后,我们需要添加一个 Serial Version ID,它相当于类的版本号。这个 ID 我们可以显式声明也可以让编译器自己计算。通常我建议显式声明会更加稳妥,因为隐式声明假如类发生了一点点变化,进行反序列都会由于 serialVersionUID 改变而导致 InvalidClassException 异常。 |
||||||
|
|
||||||
|
3. 构造方法 |
||||||
|
|
||||||
|
Serializable 的反序列化默认是不会执行构造方法的,它是根据数据流中对 Object 的描述信息创建对象的。如果一些逻辑依赖构造方法,就可能会出现问题。 |
||||||
|
|
||||||
|
##### Parcelable |
||||||
|
|
||||||
|
由于 Java 的 Serializable 的性能较低,Android 需要重新设计一套更加轻量且高效的对象序列化和反序列机制。Parcelable 正是在这个背景下产生的,它核心的作用就是为了解决 Android 中大量跨进程通信的性能问题。 |
||||||
|
|
||||||
|
Parcelable 只会在内存中进行序列化操作,并不会将数据存储到磁盘里。使用起来相比 Serializable 会复杂很多,需要手动添加自定义代码。 |
||||||
|
|
||||||
|
虽然可以通过取巧的方法可以实现 Parcelable 的永久存储,但是它也存在两个问题。 |
||||||
|
|
||||||
|
1. 系统版本的兼容性 |
||||||
|
|
||||||
|
由于 Parcelable 设计本意是在内存中使用的,我们无法保证所有 Android 版本的 Parcel.app 实现都完全一致。如果不同系统版本实现有所差异,或者有厂商修改了实现,可能会存在问题。 |
||||||
|
|
||||||
|
2. 数据前后兼容性 |
||||||
|
|
||||||
|
Parcelable 并没有版本管理的设计,如果我们类的版本出现升级,写入的顺序以及字段类型的兼容都需要格外注意,这也带来了很大的维护成本。 |
||||||
|
|
||||||
|
一般来说,如果需要持久化存储的话,一般还是不得不选择性能更差的 Serializable 方案。 |
||||||
|
|
||||||
|
##### Serial |
||||||
|
|
||||||
|
Twitter 开源的高性能序列化方案 [Serial](https://github.com/twitter/Serial/blob/master/README-CHINESE.rst/)。 |
||||||
|
|
||||||
|
#### 数据序列化 |
||||||
|
|
||||||
|
Serial 性能看起来还不错,但是对象的序列化要记录的信息还是比较多,在操作比较频繁的时候,对应用的影响还是不少的,这个时候可以选择数据的序列化。 |
||||||
|
|
||||||
|
##### Json |
||||||
|
|
||||||
|
##### Protocol Buffers |
||||||
|
|
@ -0,0 +1,24 @@ |
|||||||
|
--- |
||||||
|
崩溃优化 |
||||||
|
--- |
||||||
|
|
||||||
|
#### 目录 |
||||||
|
|
||||||
|
1. Android 的两种崩溃 |
||||||
|
2. 如何衡量崩溃 |
||||||
|
|
||||||
|
#### Android 的两种崩溃 |
||||||
|
|
||||||
|
Android 崩溃分为 Java 崩溃和 Native 崩溃。 |
||||||
|
|
||||||
|
简单来说,Java 崩溃就是在 Java 代码中,出现了未捕获异常,导致程序异常退出。那么 Native 崩溃又是怎么产生的呢?一般都是因为在 Native 代码中访问非法地址,也可能是地址对齐出现了问题,或者发生了程序主动 abort,这些都会产生对应的 signal 信号,导致程序异常退出。 |
||||||
|
|
||||||
|
##### Java 崩溃 |
||||||
|
|
||||||
|
通过实现 UncaughtExceptionHandler 接口,来处理未捕获的异常。 |
||||||
|
|
||||||
|
##### Native 崩溃 |
||||||
|
|
||||||
|
使用 [Breakpad](https://chromium.googlesource.com/breakpad/breakpad/+/master) 开源项目。 |
||||||
|
|
||||||
|
#### |
@ -0,0 +1,4 @@ |
|||||||
|
--- |
||||||
|
电量优化 |
||||||
|
--- |
||||||
|
|
@ -0,0 +1,165 @@ |
|||||||
|
--- |
||||||
|
JVM 垃圾收集器与内存分配策略 |
||||||
|
--- |
||||||
|
|
||||||
|
#### 目录 |
||||||
|
|
||||||
|
1. 概述 |
||||||
|
2. 对象是否存活 |
||||||
|
3. 再谈引用 |
||||||
|
4. 垃圾收集算法 |
||||||
|
5. HotSpot 的算法实现 |
||||||
|
6. 垃圾收集器 |
||||||
|
7. 内存分配和回收策略 |
||||||
|
|
||||||
|
#### 概述 |
||||||
|
|
||||||
|
在前面介绍了 Java 内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈这三个区域是线程私有的,也就是随着线程而生,伴随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈操作。每一个栈帧中分配多少内存基本是在类结构确定下来就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟着回收了。而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内容可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道创建了哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的也是这部分内存。 |
||||||
|
|
||||||
|
#### 对象是否存活? |
||||||
|
|
||||||
|
垃圾收集器在堆进行回收前,需要判断对象是否不被使用了。判活有以下几种办法: |
||||||
|
|
||||||
|
1. 引用计数法 |
||||||
|
|
||||||
|
给对象添加一个引用计数器,每当有一个地方引用时,计数器值就加一,当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。 |
||||||
|
|
||||||
|
引用计数法实现简单,判断效率高,但是 Java 虚拟机里面并没有选用引用计数法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。 |
||||||
|
|
||||||
|
2. 可达性分析 |
||||||
|
|
||||||
|
这个算法的基本思路就是通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当有一个对象到 GC Roots 没有任何引用链相连,即不可达,则证明此对象是不可用的。 |
||||||
|
|
||||||
|
在 Java 语言中,可作为 GC Roots 的对象包括下面几种: |
||||||
|
|
||||||
|
- 虚拟机栈中引用的对象 |
||||||
|
- 方法区中类静态属性引用的对象 |
||||||
|
- 方法区中常量引用的对象 |
||||||
|
- 本地方法栈中 JNI(即一般说是 Native 方法)引用的对象 |
||||||
|
|
||||||
|
#### 再谈引用 |
||||||
|
|
||||||
|
如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。 |
||||||
|
|
||||||
|
在 JDK1.2 之后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用这四种。 |
||||||
|
|
||||||
|
##### 强引用 |
||||||
|
|
||||||
|
在程序代码中普遍存在,类似 Object object = new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收被引用的对象。 |
||||||
|
|
||||||
|
##### 软引用 |
||||||
|
|
||||||
|
用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。JDK1.2 提供 SoftReference 类来实现软引用。 |
||||||
|
|
||||||
|
##### 弱引用 |
||||||
|
|
||||||
|
被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK1.2 之后,提供了 WeakReference 类来实现弱引用。 |
||||||
|
|
||||||
|
##### 虚引用 |
||||||
|
|
||||||
|
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在 JDK1.2 之后,提供了 PhantomReference 类来实现虚引用。 |
||||||
|
|
||||||
|
#### 垃圾收集算法 |
||||||
|
|
||||||
|
##### 标记 - 清除算法 |
||||||
|
|
||||||
|
首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它主要有两点不足:一个是效率问题,标记和清除两个过程的效率都不高;另外一个是空间问题,标记清除后会产生大量的内存碎片。 |
||||||
|
|
||||||
|
![](https://i.loli.net/2019/02/23/5c70bb8f2f084.jpg) |
||||||
|
|
||||||
|
##### 复制算法 |
||||||
|
|
||||||
|
为了解决效率问题,复制算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了,就将还存活的对象复制到另外一块,然后将已使用的那块内存空间一次清理掉。实现简单,运行高效。但是这种算法的代价就是将内存缩小了原来的一半。 |
||||||
|
|
||||||
|
现在虚拟机都采用复制算法来回收新声代。新声代的对象 98% 的对象都是朝生夕死的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 区和两块较小的 Survivor 空间,每次使用 Eden 区和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活的对象一次性的复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1。也就是只有 10% 的内存会 ”浪费“。当然,如果 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。 |
||||||
|
|
||||||
|
![](https://i.loli.net/2019/02/23/5c70bbc54fb78.jpg) |
||||||
|
|
||||||
|
##### 标记 - 整理算法 |
||||||
|
|
||||||
|
复制算法在对象存活率比较高的时候是非常低效的,更关键的是,如果不想浪费 50% 的内存空间,就要有额外的空间进行分配担保,所以老年代一般不会选用复制算法。 |
||||||
|
|
||||||
|
和标记清除算法的标记过程一致,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。 |
||||||
|
|
||||||
|
![](https://i.loli.net/2019/02/23/5c70bbaad5038.jpg) |
||||||
|
|
||||||
|
#### HotSpot 的算法实现 |
||||||
|
|
||||||
|
##### 枚举根节点 |
||||||
|
|
||||||
|
可达性分析对执行时间的敏感还体现在 GC 停顿上,因为这项分析工作必须确保一致性。一致性的意思是指整个分析过程中整个系统看起来像被冻结在某个时间点上,不可以出现分析过程中引用关系还在不断变化的情况,这点是导致 GC 进行时必须停顿掉所有的线程。 |
||||||
|
|
||||||
|
##### 安全点 |
||||||
|
|
||||||
|
程序执行时并非在所有的地方都能停顿下来开始 GC,只有在到达安全点时才能暂停。那么如何让 GC 发生时所有的线程都跑到安全点上在停顿下来呢?有两种方案可供选择:抢先式中断和主动式中断。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应 GC 事件。 |
||||||
|
|
||||||
|
主动式中断的思想是当 GC 需要中断线程时,不直接对线程操作,仅仅是设置一个标志,各个线程执行时主动去轮训这个标志,发现中断标志时就自己中断挂机。轮训标志的地方和安全点是重合的。 |
||||||
|
|
||||||
|
##### 安全区 |
||||||
|
|
||||||
|
可以看作是被扩展的安全点。 |
||||||
|
|
||||||
|
#### 垃圾收集器 |
||||||
|
|
||||||
|
如果说垃圾收集算法是对内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。如果两个收集器之间存在连线,就说明它们可以搭配使用: |
||||||
|
|
||||||
|
![](https://i.loli.net/2019/02/23/5c70bdfb374c1.jpg) |
||||||
|
|
||||||
|
##### Serial 收集器 |
||||||
|
|
||||||
|
单线程收集器,它在进行垃圾收集时,必须暂停其他所有的工作现场。 |
||||||
|
|
||||||
|
##### ParNew 收集器 |
||||||
|
|
||||||
|
其实是 Serial 收集器的多线程版本。 |
||||||
|
|
||||||
|
##### Parallel Scavenge 收集器 |
||||||
|
|
||||||
|
是一个使用复制算法的新生代收集器,又是并行的多线程收集器。但是它的关注点和其它收集器不同,CMS 等收集器的关注点是尽可能的缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量,所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值。例如虚拟机总运行了 100 分钟,其中垃圾回收花费了一分钟,那么吞吐量就是 99%。 |
||||||
|
|
||||||
|
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率的利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。 |
||||||
|
|
||||||
|
##### Serial Old 收集器 |
||||||
|
|
||||||
|
是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。 |
||||||
|
|
||||||
|
##### Parallel Old 收集器 |
||||||
|
|
||||||
|
是 Parallel Scavenge 收集器的老年代版本,使用多线程和标记-整理算法。 |
||||||
|
|
||||||
|
##### CMS 收集器 |
||||||
|
|
||||||
|
CMS 收集器是一种以获取最短回收停顿时间为目标的收集器。基于标记清除算法实现,并发收集,低停顿。 |
||||||
|
|
||||||
|
##### G1 收集器 |
||||||
|
|
||||||
|
G1 是一款面向服务端应用的垃圾收集器,有以下特点: |
||||||
|
|
||||||
|
1. 并行和并发 |
||||||
|
2. 分代收集 |
||||||
|
3. 空间整合 |
||||||
|
4. 可预测的停顿 |
||||||
|
|
||||||
|
#### 内存分配策略 |
||||||
|
|
||||||
|
对于内存分配,往大方向讲,就是在堆上分配,对象主要分配在新声代的 Eden 区上,如果启动了本地线程分配缓冲区,将按线程优先在 TLAB 上分配。少数情况下也可以直接分配在老年代。 |
||||||
|
|
||||||
|
##### 对象优先在 Eden 区分配 |
||||||
|
|
||||||
|
大多数情况下,对象在新生代 Eden 区分配,当 Eden 区没有足够的空间进行分配时,虚拟机将发起一次 Minor GC。 |
||||||
|
|
||||||
|
- 新生代 GC(Minor GC) |
||||||
|
|
||||||
|
指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕死的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。 |
||||||
|
|
||||||
|
- 老年代 GC(Major GC / Full GC) |
||||||
|
|
||||||
|
指发生在老年代的 GC,出现 Major GC,经常会伴随至少一次的 Minor GC,Major GC 的速度一般会比 Minor GC 慢十倍以上。 |
||||||
|
|
||||||
|
##### 大对象直接进入老年代 |
||||||
|
|
||||||
|
所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组。一般来说,超过 3M 的对象会直接在老年代进行分配。 |
||||||
|
|
||||||
|
##### 长期存活的对象将进入老年代 |
||||||
|
|
||||||
|
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收就必须能识别哪些对象应该放在新生代还是老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。对象在 Survivor 区每熬过一次 Minor GC,年龄就会增加一岁。当它的年龄增加到一定程度,默认是 15,就将会被晋升到老年代中。 |
@ -0,0 +1,14 @@ |
|||||||
|
--- |
||||||
|
一篇文章总结完 JVM 知识 |
||||||
|
--- |
||||||
|
|
||||||
|
#### 思维导图 |
||||||
|
|
||||||
|
![](https://i.loli.net/2019/02/23/5c714b07b6760.png) |
||||||
|
|
||||||
|
#### 前言 |
||||||
|
|
||||||
|
学习 JVM 直接看《深入理解 Java 虚拟机》这本书就够了,而本篇文章就对书里知识的总结,大致可以分为: |
||||||
|
|
||||||
|
[]() |
||||||
|
|
@ -0,0 +1,101 @@ |
|||||||
|
--- |
||||||
|
Java 内存区域与 HotSpot 虚拟机 |
||||||
|
--- |
||||||
|
|
||||||
|
#### 目录 |
||||||
|
|
||||||
|
1. 运行时数据区域 |
||||||
|
- 程序计数器 |
||||||
|
- 虚拟机栈 |
||||||
|
- 本地方法栈 |
||||||
|
- Java 堆 |
||||||
|
- 方法区 |
||||||
|
- 运行时常量池 |
||||||
|
2. HotSpot 虚拟机对象探秘 |
||||||
|
- 对象的创建 |
||||||
|
- 对象的内存布局 |
||||||
|
- 对象的访问定位 |
||||||
|
|
||||||
|
#### 运行时数据区域 |
||||||
|
|
||||||
|
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。 |
||||||
|
|
||||||
|
![](https://i.loli.net/2019/02/22/5c6ff79b2e730.png) |
||||||
|
|
||||||
|
图片来自:[波仔的 Java 虚拟机内存分配机制](https://github.com/jeanboydev/Android-ReadTheFuckingSourceCode/blob/master/article/java/jvm/JVM-%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D%E6%9C%BA%E5%88%B6.md) |
||||||
|
|
||||||
|
##### 程序计数器 |
||||||
|
|
||||||
|
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。 |
||||||
|
|
||||||
|
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的位置,每条线程都需要一个独立的程序计数器,各线程之间计数器互不影响,独立存储。 |
||||||
|
|
||||||
|
如果线程执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器则为空。此内存区域是唯一一个没有规定任何 OOM 的区域。 |
||||||
|
|
||||||
|
##### 虚拟机栈 |
||||||
|
|
||||||
|
虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接地址、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。 |
||||||
|
|
||||||
|
局部变量表存放了编译期可知的各种基本数据类型,对象引用类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄,局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。 |
||||||
|
|
||||||
|
在 JVM 规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机可以动态扩展,如果扩展时无法申请到足够的内存,则会抛出 OOM。 |
||||||
|
|
||||||
|
##### 本地方法栈 |
||||||
|
|
||||||
|
本地方法栈和虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OOM。 |
||||||
|
|
||||||
|
##### Java 堆 |
||||||
|
|
||||||
|
Java 堆是虚拟机管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。 |
||||||
|
|
||||||
|
Java 堆是垃圾收集器管理的主要区域,从内存回收的角度来看,由于现在收集器基本都是采用分代手机算法,所以 Java 堆还可以细分为新生代和老年代。在细致一点有 Eden 空间、From Survivor 空间、To Survivor 空间等。从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(TLAB)。不过不论如何划分,都与存放内容无关,无论哪个区域,存放的都是对象实例,进一步划分的目的是为了更好的回收内存和更快的分配内存。 |
||||||
|
|
||||||
|
如果堆中没有内存完成实例分配,并且堆也无法再扩展时,就会抛出 OOM。 |
||||||
|
|
||||||
|
##### 方法区 |
||||||
|
|
||||||
|
方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。Java 虚拟机对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可以扩展外,还可以选择不实现垃圾回收。相对而言,垃圾回收在这个区域是比较少出现的。 |
||||||
|
|
||||||
|
运行时常量池: |
||||||
|
|
||||||
|
运行时常量池也是方法区的一部分,常量池用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。 |
||||||
|
|
||||||
|
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有在编译期才能生成,也就是并非预置入 Class 文件中的常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量池放入池中,这种特性被开发人员利用比较多的便是 String 类的 intern 方法。 |
||||||
|
|
||||||
|
既然运行时常量池也是方法区的一部分,自然会受到方法区内存的限制,当常量池无法申请到内存时则会抛出 OOM。 |
||||||
|
|
||||||
|
#### HotSpot 虚拟机对象探秘 |
||||||
|
|
||||||
|
下面就以常用的虚拟机 HotSpot 和常用的内存区域 Java 堆为例,深入探讨 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。 |
||||||
|
|
||||||
|
##### 对象的创建 |
||||||
|
|
||||||
|
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。 |
||||||
|
|
||||||
|
在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务就等同于把一块确定大小的内存从 Java 堆中划分出来。假设 Java 堆内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那么所分配内存就仅仅是把指针向空闲内存区域移动,这种分配方法称为**指针碰撞**。如果 Java 堆并不是规整的,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找出一块足够大的空间划分给对象实例,并更新表上的记录,这种分配方式称为**空闲列表**。选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否具有压缩整理功能决定。 |
||||||
|
|
||||||
|
对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改了一个指针所指向的位置,在并发情况下也并不是线程安全的。可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用原来的指针来分配内存。解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理 — 实际上虚拟机采用 CAS 配上失败重试的方法保证更新操作的原子性;另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程都在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲区(TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定。 |
||||||
|
|
||||||
|
##### 对象的内存布局 |
||||||
|
|
||||||
|
在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头、实例数据和对齐填充。 |
||||||
|
|
||||||
|
##### 对象的访问定位 |
||||||
|
|
||||||
|
Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在 Java 虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。 |
||||||
|
|
||||||
|
1. 使用句柄 |
||||||
|
|
||||||
|
Java 堆中将会划分出一块内容作为句柄池,reference 中存储的就是对象的句柄池地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。 |
||||||
|
|
||||||
|
![](https://i.loli.net/2019/02/22/5c701863a8188.jpg) |
||||||
|
|
||||||
|
2. 直接指针 |
||||||
|
|
||||||
|
reference 中存储的直接就是对象地址。 |
||||||
|
|
||||||
|
![](https://i.loli.net/2019/02/22/5c7018cc4e0e5.jpg) |
||||||
|
|
||||||
|
这两种方式各有优势,使用句柄来访问的最大的好处就是 reference 中存储的就是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是很普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要改变。 |
||||||
|
|
||||||
|
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。对于 HotSpot 虚拟机而言,它是使用第二种方式进行对象访问,但是使用句柄来访问的情况也十分常见。 |
@ -0,0 +1,109 @@ |
|||||||
|
--- |
||||||
|
Java 内存模型 |
||||||
|
--- |
||||||
|
|
||||||
|
#### 目录 |
||||||
|
|
||||||
|
1. 概述 |
||||||
|
2. 主内存和工作内存 |
||||||
|
3. 内存间交互操作 |
||||||
|
4. 对 volatile 变量的特殊规则 |
||||||
|
5. 原子性、可见性与有序性 |
||||||
|
6. 先行发生原则 |
||||||
|
|
||||||
|
#### 概述 |
||||||
|
|
||||||
|
Java 虚拟机规范试图定义一种 Java 内存模型(JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各个平台下都能达到一致的内存访问效果。 |
||||||
|
|
||||||
|
#### 主内存和工作内存 |
||||||
|
|
||||||
|
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的,不会被共享,自然就不存在竞争问题。 |
||||||
|
|
||||||
|
Java 内存模型规定了所有的变量都是存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保留了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。 |
||||||
|
|
||||||
|
![](https://i.loli.net/2019/02/23/5c7099a498a4a.jpg) |
||||||
|
|
||||||
|
这里所讲的主内存、工作内存与 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上没有任何关系。 |
||||||
|
|
||||||
|
##### 内存间交互操作 |
||||||
|
|
||||||
|
Java 内存模型中定义了以下八种操作,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于 double 和 long 类型的变量来说有例外): |
||||||
|
|
||||||
|
- lock 锁定 |
||||||
|
- unlock 解锁 |
||||||
|
- read 读取 |
||||||
|
- laod 载入 |
||||||
|
- use 使用 |
||||||
|
- assign 复制 |
||||||
|
- store 赋值 |
||||||
|
- write 写入 |
||||||
|
|
||||||
|
Java 内存模型还规定了在执行上诉八种基本操作时必须满足以下规则: |
||||||
|
|
||||||
|
1. 不允许 read、load、store 和 write 操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起了回写但主内存不接受的情况。 |
||||||
|
2. 不允许一个线程丢弃它最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存 |
||||||
|
3. 不允许一个线程无原因的把数据从工作内存同步回主内存中 |
||||||
|
4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化的变量 |
||||||
|
5. 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁 |
||||||
|
6. 如果对一个变量执行 lock 操作,那么将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值 |
||||||
|
7. 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量 |
||||||
|
8. 对一个变量执行 unlock 操作之前,必须把此变量同步回主内存中 |
||||||
|
|
||||||
|
#### 对 volatile 变量的特殊规则 |
||||||
|
|
||||||
|
当一个变量定义为 volatile 之后,它将具备两种特性: |
||||||
|
|
||||||
|
##### 保证此变量对所有线程的可见性 |
||||||
|
|
||||||
|
这里的可见性是指当一个线程修改了这个变量的值,新值对于其它线程来说是可以立即得知的,而普通变量的值在线程间传递均需要通过主内存来完成。 |
||||||
|
|
||||||
|
Java 里面的运算并非原子操作,导致 volatile 变量的运算在并发下一样是不安全的。 |
||||||
|
|
||||||
|
由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景下,我们仍然要通过加锁来保证原子性。 |
||||||
|
|
||||||
|
- 运算结果并不依赖变量的当前值,或者能够确保只有单一线程修改了变量的值 |
||||||
|
- 变量不需要与其他的状态变量共同参与不变约束 |
||||||
|
|
||||||
|
##### 禁止指令重排优化 |
||||||
|
|
||||||
|
普通的变量仅仅会保证在该方法执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这一点,这也就是 Java 内存模型中描述的所谓的 “线程内表现为串行的语义”(As - if - Serial)。 |
||||||
|
|
||||||
|
volatile 变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入很多内存屏障指令来保证处理器不会发生乱序执行。 |
||||||
|
|
||||||
|
#### 原子性、可见性与有序性 |
||||||
|
|
||||||
|
##### 原子性 |
||||||
|
|
||||||
|
我们大致可以认为基本数据类型的访问读写是具备原子性的。如果应用场景需要一个更大范围的原子性保证,Java 内存模型还提供了 lock 和 unlock 操作来满足这种需求。尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式的使用这两种操作,这两个字节码指令就对应于 Java 中的 synchronized 关键字。 |
||||||
|
|
||||||
|
##### 可见性 |
||||||
|
|
||||||
|
可见性是指当一个变量修改了共享变量的值,其他线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性,无论是普通变量还是 volatile 变量都是如此,volatile 变量的特殊规则是保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说 volatile 变量保证了多线程操作时的可见性,而普通变量则不能保证这一点。 |
||||||
|
|
||||||
|
除了 volatile 之外,Java 还有两个关键字能实现可见性,即 synchronized 和 final。同步块的可见性是由 “对变量执行 unlock 操作之前,必须先把此变量同步回主内存中” 这条规则获得,而 final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有把 this 引用传递出去,那么其他线程就能看见 final 字段的值。 |
||||||
|
|
||||||
|
##### 有序性 |
||||||
|
|
||||||
|
如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指 “线程内表现为串行的语义(As - if - Serial)”,后半句是指 “指令重排序” 现象和 “工作内存和主内存同步延迟” 现象。 |
||||||
|
|
||||||
|
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由 “一个变量在同一个时刻只允许一条线程对其进行 lock 操作” 这条规则获得的,这条规则决定了持有同一个锁的同步块只能串行的进入。 |
||||||
|
|
||||||
|
#### 先行发生原则 |
||||||
|
|
||||||
|
先行发生是指 Java 内存模型中定义的两项操作之间的偏序关系。如果是操作 A 先行发生与 B,那么操作 A 产生的影响能够被 B 观察到。下面是 Java 内存模型中天然的先行发生关系: |
||||||
|
|
||||||
|
- 程序次序规则 |
||||||
|
|
||||||
|
即程序的书写顺序。 |
||||||
|
|
||||||
|
- 管程锁定规则 |
||||||
|
|
||||||
|
一个 unlock 操作先行发生与后面对同一个锁的 lock 操作。 |
||||||
|
|
||||||
|
- volatile 变量规则 |
||||||
|
|
||||||
|
对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。 |
||||||
|
|
||||||
|
- 线程启动规则 |
||||||
|
|
||||||
|
Thread.start 方法先行发生于此线程的每一个。 |
@ -0,0 +1,250 @@ |
|||||||
|
--- |
||||||
|
类加载流程和双亲委派模型 |
||||||
|
--- |
||||||
|
|
||||||
|
#### 目录 |
||||||
|
|
||||||
|
1. 概述 |
||||||
|
2. 类加载流程 |
||||||
|
3. 双亲委派模型 |
||||||
|
|
||||||
|
#### 概述 |
||||||
|
|
||||||
|
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转化解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。 |
||||||
|
|
||||||
|
在 Java 语言里面,类型的加载、链接和初始化都是程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为 Java 应用程序提供高度的灵活性。比如我们可以自定义类加载器。 |
||||||
|
|
||||||
|
#### 类加载流程 |
||||||
|
|
||||||
|
类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载这七个阶段。其中验证、准备和解析这三个阶段统称为链接。 |
||||||
|
|
||||||
|
![](https://i.loli.net/2019/02/23/5c70d2f627876.jpg) |
||||||
|
|
||||||
|
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定。 |
||||||
|
|
||||||
|
##### 加载 |
||||||
|
|
||||||
|
在加载阶段,虚拟机需要完成以下三件事情: |
||||||
|
|
||||||
|
1. 通过一个类的全限定名来获取定义此类的二进制字节流 |
||||||
|
2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构 |
||||||
|
3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口 |
||||||
|
|
||||||
|
相对于类加载过程的其他阶段,一个非数组类的加载阶段是开发人员可控性最强的,因为加载阶段即可以使用系统类加载器,也可以自定义类加载。 |
||||||
|
|
||||||
|
对于数组类而言,情况有些不同,数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建。 |
||||||
|
|
||||||
|
加载阶段与链接阶段的部分内容(如一部分字节码文件格式校验动作)是交叉进行的,加载阶段尚未完成,链接阶段可能已经开始,但是这些夹在加载阶段之中进行的动作,仍然属于链接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。 |
||||||
|
|
||||||
|
##### 验证 |
||||||
|
|
||||||
|
验证是链接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。 |
||||||
|
|
||||||
|
验证是虚拟机对自身保护的一项重要工作,这个阶段大致上会完成下面四个阶段的校验工作:文件格式校验、元数据校验、字节码校验、符号引用校验。 |
||||||
|
|
||||||
|
- 文件格式校验 |
||||||
|
|
||||||
|
验证字节流是否符合 Class 文件格式规范,保证输入的字节流能正确解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。包括验证是否以魔数 0xCAFEBABE 开头,验证主次版本是否在当前虚拟机的处理范围之内等等。 |
||||||
|
|
||||||
|
只有通过这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的三个验证阶段完全基于方法区的存储结构进行的,不会再直接操作字节流。 |
||||||
|
|
||||||
|
- 元数据校验 |
||||||
|
|
||||||
|
这一阶段是对字节码描述的信息进行语义分析,以保证其描述信息符合 Java 语言规范的要求。 |
||||||
|
|
||||||
|
验证这个类是否有父类(除了 Object 类以外,所有类都应当由其父类)、验证这个类是否继承了不允许被继承的类(被 final 修饰的类)等等。 |
||||||
|
|
||||||
|
其实这个阶段大多数情况下是不会发生问题的,因为实际开发中,这些问题都能被编译器发现。 |
||||||
|
|
||||||
|
- 字节码校验 |
||||||
|
|
||||||
|
这个阶段是整个验证过程中最为复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法、符合逻辑的。 |
||||||
|
|
||||||
|
这个阶段主要是对类的方法体进行校验,比如保障方法体中安全的类型转换等等。 |
||||||
|
|
||||||
|
- 符号引用校验 |
||||||
|
|
||||||
|
符号引用校验可以看作是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。 |
||||||
|
|
||||||
|
验证符号引用中通过字符串描述的全限定名是否能找到对应的类、富豪引用中的类、字段、方法的访问修饰符(private、protected、public、default)是否具有正确的语义等。 |
||||||
|
|
||||||
|
##### 准备 |
||||||
|
|
||||||
|
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。 |
||||||
|
|
||||||
|
需要注意的是,这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆上。其次,这里所说的变量初始值是该数据类型的零值。 |
||||||
|
|
||||||
|
```java |
||||||
|
public static int value = 233; |
||||||
|
``` |
||||||
|
|
||||||
|
变量 value 在准备阶段过后的初始值为 0 而不是 233,因为这时候尚未开始执行任何 Java 方法。当然,如果又加上 final 修饰,那么初始值就直接赋值了。 |
||||||
|
|
||||||
|
##### 解析 |
||||||
|
|
||||||
|
解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。 |
||||||
|
|
||||||
|
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。 |
||||||
|
|
||||||
|
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局有关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那么引用的目标必定已经在内存中存在了。 |
||||||
|
|
||||||
|
##### 初始化 |
||||||
|
|
||||||
|
初始化阶段是类加载过程的最后一步,这个阶段才真正开始执行类中定义的 Java 程序代码(或者说是字节码)。 |
||||||
|
|
||||||
|
初始化阶段是执行类构造器 \<clinit>() 方法的过程。 |
||||||
|
|
||||||
|
1. \<clinit>() 方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的。编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。 |
||||||
|
|
||||||
|
```java |
||||||
|
public class SuperClass { |
||||||
|
static { |
||||||
|
value = 1; //可以赋值 |
||||||
|
System.out.println(value); //编译器提示非法向前引用 |
||||||
|
} |
||||||
|
private static int value = 233; |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
2. \<clinit>() 方法与类的构造函数(或者说实例构造器\<init>()方法)不同,它不需要显式的调用父构造器,虚拟机会保证在子类的 \<clinit>() 方法执行之前,父类的 \<clinit>() 方法已经执行完毕。因此,第一个被执行的 \<clinit>() 方法的类肯定是 java.lang.Object。 |
||||||
|
|
||||||
|
3. 由于父类的 \<clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。 |
||||||
|
|
||||||
|
```java |
||||||
|
public class SuperClass { |
||||||
|
public static int value = 233; |
||||||
|
static { |
||||||
|
value = 1; |
||||||
|
} |
||||||
|
} |
||||||
|
public class SubClass extends SuperClass { |
||||||
|
public static int value=SuperClass.value; |
||||||
|
/** |
||||||
|
* 输出 1 |
||||||
|
*/ |
||||||
|
public static void main(String[] args) { |
||||||
|
System.out.println(value); |
||||||
|
} |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
4. \<clinit>() 方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 \<clinit>() 方法。 |
||||||
|
|
||||||
|
5. 接口中不能有静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 \<clinit>() 方法。但接口与类不同的是,执行接口的 \<clinit>() 方法不需要先执行父接口的 \<clinit>() 方法,只有当父接口中定义的变量使用时,父接口才会初始化。 |
||||||
|
|
||||||
|
6. 虚拟机会保证一个类的 \<clinit>() 方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 \<clinit>() 方法,其它线程都需要阻塞等待,直到活动线程执行 \<clinit>() 方法完毕。 |
||||||
|
|
||||||
|
虚拟机严格规定有且只有五种情况必须立即对类进行初始化: |
||||||
|
|
||||||
|
1. 遇到 new、getstatic、putstatic 或 invokestatic 这四条字节码指令时,如果类没有进行初始化,则需要先初始化。生成这四条指令的最常见的场景是:使用 new 关键字实例化对象的时候、调用类的静态属性或方法等 |
||||||
|
2. 使用反射 |
||||||
|
3. 当初始化一个类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化 |
||||||
|
4. 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机需要先初始化这个主类 |
||||||
|
5. 当使用 JDK1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化 |
||||||
|
|
||||||
|
这五种场景中的行为,称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。 |
||||||
|
|
||||||
|
被动引用例一: |
||||||
|
|
||||||
|
```java |
||||||
|
public class SuperClass { |
||||||
|
static { |
||||||
|
System.out.println("SuperClass init !"); |
||||||
|
} |
||||||
|
|
||||||
|
public static int value = 233; |
||||||
|
} |
||||||
|
|
||||||
|
public class SubClass extends SuperClass { |
||||||
|
static { |
||||||
|
System.out.println("SubClass init !"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public class ClassLoaderTest { |
||||||
|
public static void main(String[] args) { |
||||||
|
System.out.println(SubClass.value); |
||||||
|
} |
||||||
|
} |
||||||
|
//输出: |
||||||
|
/** |
||||||
|
* SuperClass init ! |
||||||
|
* 233 |
||||||
|
*/ |
||||||
|
``` |
||||||
|
|
||||||
|
对于静态字段,只有直接定义了这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。 |
||||||
|
|
||||||
|
被动引用例二: |
||||||
|
|
||||||
|
```java |
||||||
|
public class ClassLoaderTest { |
||||||
|
public static void main(String[] args) { |
||||||
|
SuperClass[] superClasses = new SuperClass[10]; |
||||||
|
} |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
通过数组定义来引用类,不会触发此类的初始化。 |
||||||
|
|
||||||
|
被动引用例三: |
||||||
|
|
||||||
|
```java |
||||||
|
public class SuperClass { |
||||||
|
static { |
||||||
|
System.out.println("SuperClass init !"); |
||||||
|
} |
||||||
|
|
||||||
|
public static int value = 233; |
||||||
|
public static final String TEST = "TEST"; |
||||||
|
} |
||||||
|
|
||||||
|
public class ClassLoaderTest { |
||||||
|
public static void main(String[] args) { |
||||||
|
System.out.println(SuperClass.TEST); |
||||||
|
} |
||||||
|
} |
||||||
|
//输出: |
||||||
|
/** |
||||||
|
* TEST |
||||||
|
*/ |
||||||
|
``` |
||||||
|
|
||||||
|
常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。 |
||||||
|
|
||||||
|
接口的加载过程与类加载过程稍有一些不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点与类是一致的,上面的代码都是用静态语句块来输出初始化信息的,而接口中不能使用 static{} 语句块,但编译器仍然为接口生成了 \<clinit>() 类构造器,用于初始化接口中定义的成员变量。接口与类真正有区别的是前面讲诉的五种初始化场景中的第三种:一个接口在初始化时,并不要求其父接口完全都完成初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。 |
||||||
|
|
||||||
|
#### 双亲委派模型 |
||||||
|
|
||||||
|
##### 类加载器 |
||||||
|
|
||||||
|
虚拟机设计团队把类加载阶段中的 “通过一个类的全限定名来获取描述此类的二进制字节流” 这个动作放到了 Java 虚拟机的外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码模板称为 “类加载器”。 |
||||||
|
|
||||||
|
##### 类与类加载器 |
||||||
|
|
||||||
|
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性。 |
||||||
|
|
||||||
|
##### 双亲委派模型 |
||||||
|
|
||||||
|
从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;另外一种就是其他类加载器,这些类加载器都是由 Java 语言实现,独立于虚拟机外部,并且都继承自抽象类 java.lang.ClassLoader。 |
||||||
|
|
||||||
|
当然,类加载器还可以划分的更细致一点: |
||||||
|
|
||||||
|
- 启动类加载器 |
||||||
|
- 扩展类加载器 |
||||||
|
- 应用程序类加载器 |
||||||
|
|
||||||
|
如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。 |
||||||
|
|
||||||
|
![](https://i.loli.net/2019/02/23/5c71425e71abe.png) |
||||||
|
|
||||||
|
以上就是类加载器之间的层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里的类加载器之间的关系一般不会以继承的关系来实现,而是使用组合来复用父加载器的代码。 |
||||||
|
|
||||||
|
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求都应该传到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。 |
||||||
|
|
||||||
|
使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都会委托给处于模型顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并存放在程序的 ClassPath 中,那么系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将变得混乱。 |
||||||
|
|
||||||
|
双亲委派模型对于保证 Java 程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在 java.lang.ClassLoader 的 loadClass 方法中: |
||||||
|
|
||||||
|
先检查是否已经被加载过了,如果没有加载则调用父加载器的 loadClass 方法,若父加载器为空,则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出 ClassNotFoundException 异常后,在调用自己的 findClass 方法进行加载。 |
||||||
|
|
After Width: | Height: | Size: 88 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 19 KiB |
Loading…
Reference in new issue