0%

一、Java 线程安全停止工作

interrupt() 方法是对线程发起中断,只是改写了一下 标志位!不代表线程要立即停止工作。不过可以根据这个标志位来决定是否停止线程了。

基于这一点,我们可以说 JDK 中的线程是协作式的,而不是抢占式的。

所以,我们可以在 while 循环中,可以通过 isInterrupted() 来判断标记位状态,来判断是否终止线程,比如:

1
2
3
4
5
6
7
8
9
10
11
static class UsetThread extends Thread {
@Override
public void run() {
super.run();
while(!isInterrupted()) {
System.out.println("线程正在执行");
System.out.println("线程isInterrupted标记位:" + isInterrupted());
}
System.out.println("最终线程isInterrupted标记位:" + isInterrupted());
}
}

在这里,需要注意线程自己成员方法 isInterrupt() 以及 Thread.isInterrupt() 方法,如果是后者的话,它发现Thread.isInterrupt() == true 的时候,会又置为 false。所以上述在while 循环外面输出的最终线程xxx会为false(而如果用线程的成员方法isInterrupt() 这里会输出true ):

最终线程isInterrupted标记位:false 。不过我在本地运行(JDK1.8)的时候,并没有 Thread.isInterrupted() 这个方法了,可能高版本不见了

还是推荐使用 isInterrupted() 来终止线程

博客或者很多书籍上都会定义一个 volatile 类型的boolean 值 isCancel 来判断是否要结束线程的运行,如下所示:

1
2
3
while(!isCancel) {
doSomething();
}

但是,其实我们并不推荐这样做,因为这样的话,假如我在里面实现了Thread.sleep() 操作,这时候,我压根就不会判断到这个 isCacel 变量:

1
2
3
4
while(!isCancel) {
Thread.sleep(2000);
doSomething();
}

但是,在 sleep 阶段我们可以照常响应 interrupt 操作:

1
2
3
4
5
6
7
8
while(!isInterrupted()) {
try {
sleep(300);
} catch (InterruptedException e) {
System.out.println("in InterruptedException 中,isInterrupted = " + isInterrupted());
throw new RuntimeException(e);
}
}

这里是会响应try-catch 的,但是这里要注意一点,这个 println 会输出 :

in InterruptedException 中,isInterrupted = false

也就是在 catch 里面,我们去判断 isInterrupt 居然还是 false,这是因为抛出这个异常之后,又会将标志位置为 false,所以如果要真正让状态对,还需要我们在 catch 代码中手动实现 interrupt 方法:

1
2
3
4
5
6
7
8
9
while(!isInterrupted()) {
try {
sleep(300);
} catch (InterruptedException e) {
interrupt();
System.out.println("in InterruptedException 中,isInterrupted = " + isInterrupted());
e.printStackTrace();
}
}

sleep 、wait 、join 等方法在使用的时候都会 catch InterruptedException 异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
try {
Thread.sleep(400);
} catch (InterruptedException e) {
e.printStackTrace();
}

try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}

try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}

那么,为什么要这么设计成在 catch InterruptedException 的时候,isInterrupted 还是 false 呢?也就是说如果不这样会有什么问题呢?

假设 sleep 过程持有了资源,如果中断来了直接中断,没有给程序员干预的时间,可能造成资源不能释放,造成死锁。并且注意一点:死锁状态的线程,是不会理会中断的。

所以,要让你自己处理完自己的操作之后(比如释放锁资源),才自己去设置 interrupt = true,自主性更强。像上述第二段代码,自己加了 interrupt 之后,再次 while 循环就不会进去了。

二、Thread 的 run 和 start

new Thread 的时候,只是创建了一个 Thread 类的实例而已。如果直接调用 Thread.run() 方法,就是和普通对象的普通方法是一样的,并不会新开线程;只有调用 start 方法,才会启用新的线程。

三、Thread 的 join 方法

线程 A 执行,这时候如果在 A 线程中调用线程B的 join 方法,则 A 线程会挂起,等 B 线程执行完成后,A 线程再继续执行。

所以,面试问怎么保证 2 个线程顺序执行,怎么办?那就使用 join() 方法咯。

四、线程的优先级

Java 里面的优先级级数和操作系统里面的级数是不一定相同的。最终有没有用,还得看操作系统的决策,所以优先级高的线程,获得的时间片不一定多。

五、守护线程

当所有的用户线程和非守护线程结束后,守护线程才结束掉,我们通过 thread.setDaemon(true); 操作即可将线程设置为守护线程。

在守护线程的 run() 方法中,finally 都不一定能给保证执行(用户线程是可以保证finally执行),这是为什么呢,资源还怎么释放呢?这是因为,一般守护线程关闭的时候,整个进程都要结束了,所以这时候资源都是会一起释放了,这时候守护资源也无需保证 finally 一定执行了。

一般适合做后台调度和支持性工作。

进程是申请资源的基本单位,线程是cpu 时间片的基本单位。

一、cpu 核心与线程数

目前一个 cpu 上可以有多个核心,cpu 上一个核心可以对应一个线程。

Intel 的 超线程技术,可以实现 1 个cpu 核心对应 2 个逻辑处理器。所以在 windows上你查看性能的时候,发现只有 4 核心,但是有 8 个逻辑处理器。

二、线程轮转

1.6G 的 CPU 执行一条指令一般在 0.6ns (0.6纳秒)级别,所以是非常快的。但是线程的上下文切换时非常耗时的,一般耗时在 2W 个 CPU 周期级别。

三、并行 和 并发

  • 并行就是同时进行,比如,有 2 个咖啡机,就可以同时 2 个人并行打咖啡

  • 并发:说并发,一定是有时间单位的,会有并发量的说法。所以基于上面的例子来说,只有一台咖啡机,大家轮着来,这时候就有类似 这个咖啡机每小时的并发量是 10,就是给10个人提供服务。

四、高并发的意义

  • 充分利用硬件资源。比如有四核,但是你只有一个线程

  • 用户响应。比如商城 App ,下单之后,会给快递发送消息,给用户发送短信等等,这些其实都是互相独立的,不用单线程进行

但是,多线程不一定快,因为有竞争锁的过程,可能比单线程还慢

OS 对线程数总数是有限制的,比如 Linux 1000个,windows 2000 个

线程都会有栈空间的,所以线程数量太多的话,空间占用都很可观

句柄和文件描述符也都有数量上限

Java 程序天生就是多线程的,比如,你写个main方法,简单写几行代码,比如下面的代码:

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) throws Exception{
//Java 虚拟机线程系统的管理接口
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
//不需要获取同步的monitor 和 synchronizer 信息,仅仅获取线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
for (ThreadInfo threadInfo: threadInfos) {
System.out.println("[" + threadInfo.getThreadId() + "]" + threadInfo.getThreadName());
}
}

上面的 ThreadMXBean就是用来查看当前虚拟机所有的线程的,我们可以看到有多个线程在运行,运行的结果可能会如下所示:

[1]main
[8]Reference Handler
[9]Finalizer
[10]Signal Dispatcher
[11]Attach Listener
[17]Common-Cleaner
[18]Monitor Ctrl-Break
[19]Notification Thread

其中 main 线程我们能理解,就是我们的主线程; Finalizer 方法也好理解,因为 Object 类中有个 finalize() 方法,我们在学习 GC 的时候了解过,如果对象不可达,但是重写了 finalize() 方法还没执行的话,会交给一个 低优先级(守护)线程去执行,但是不保证 finalize() 方法执行完成。说的就是这个 Finalizer 线程。

五、开启新线程的方式

到底有几种方式,其实挺纠结的,那么我们就以 Java 的 Thread.java 类的官方文档来看:

There are two ways to create a new thread of execution

上面说的就是 2 种:

  • 扩展 Thread 类(继承一下Thread),重写 run 方法。

  • Runnable 接口。比如: UserRunnable implements Runnable,之后,在 new Thread的时候,将这个 UserRunnable 作为参数传进去

类似 Callable 之类的其实就是 Runnable 接口来实现新线程的。

Thread 和 Runnable 二者的区别

Thread 是 Java 对线程的抽象,而 Runnable 是对任务的抽象

六、线程的停止

stop 、destroy suspend 、resume 等线程方法已经被废弃了,因为他们停止的时候,不一定正常释放资源。比如要写文件 10k ,但是才写了 4k,就stop 了。

但是 interrupt 方法可以作为我们停止线程的一个方法。

一、背景

自己编译的模拟器,它可以在源码上打log,做到可控。并且,所有的行为都会经过模拟器,比如后面你的url请求

二、常用的思路

加固思路有以下几种:

  • 反模拟器,一旦发现模拟器,就停止核心代码运行

  • 代码虚拟化,自己写的代码先运行在自己写的虚拟机上,后续再运行到系统的虚拟机上
    加密,核心代码是以压缩或者加密形式存在的,被分割成一段一段的,需要的时候,再把目标段加载到内存。

  • 加壳方案,分为壳dex和源dex,壳不加密,源加密

关于第 3 种加固方案的总体示意图如下:

加固方案概览

最后为什么有个签名,因为加固过程会修改 APK,必然破坏了 APK ,因此需要重新签名。以上方案值得我们的思考的有几点:

  • 壳dex 与 源 dex 可以随意拼凑吗?

  • 壳 dex 怎么生成?

  • 如何签名?

  • 如果运行新的 apk(如何脱壳)?

四、Apk打包过程

简单说一下 APK 的打包过程,整体流程如下图所示:

Android打包流程

几个重要的过程:

  1. 首先 aapt 工具将资源文件生成 R.java 文件

  2. aidl 工具将 aidl 文件生成 Java 文件

  3. 将上述的 java 文件、项目自己的 java 文件,通过 Java compiler 一起生成 .class 文件

  4. .class 文件通过 dx 工具生成 dex 文件

  5. 将 dex 文件和那些资源文件一起压缩生成 apk

  6. 签名

加壳

生成原始 apk ,然后解压到目录 unzip 中文件夹中,当然,要把之前的签名文件给剔除;之后,找到所 unzip 中所有的 dex 文件,进行加密。

接下来,将项目中的一个 lib 生成的 aar 转成 dex 文件, dx 工具可以将 jar 转成 dex,当然,这个 lib 有自己的 Application ,在正式使用的时候,上述 原始 apk 中的 Application 在最开始的时候要通过反射的方式调用这个 lib 的 Application ,让其形式上作为 App 的默认 Application ,以便后续的解密。

将加密的 dex 和 aar 转成的 dex 一起放入 unzip 中,这样 ,unzip 文件夹中有了 加密的 dex 和 lib 的dex ,以及原始 apk 中所有的 资源,这时候通过 zip 压缩,就能将 unzip 中所有的内容压缩起来,改名 apk (这个打包apk 的过程需要确认)

最后,对这个 apk 进行签名,就形成了正式的apk 。

脱壳

上述打包的 APK 最终需要安装使用,安装成功后,

我们的安装包都会安装在

/data/data/包名/files/fake_apk/

这种目录下,在壳的 Application (那个lib 中的 Application)的 attachBaseContext 的时候,我们可以将这个apk加载进来,然后过滤出所有加密的 dex 文件,之后解密,然后将这些解密后的文件存入到指定的目录,后续就使用这些已经解密的dex 。当然,我们不需要每次都去解密这个 dex ,将其存起来就好了,下次直接使用。

对称加密、非对称加密

看了,但是笔记略

后续要根据视频内容自己写一下整个的加壳、脱壳的过程

数据流是一组有序、有起点和终点的字节的数据序列,包括输入流和输出流。

一、IO 简介

流序列中的数据既可以是未经过加工的原始二进制数据,也可以是经过一定编码处理后符合某种格式规定的特定数据。在Java 中流分为 2 种: 字节流 和 字符流

  • 字节流: 数据流中最小的数据单位是字节
  • 字符流:数据流中最小的数据单元是字符,Java 中的字符是 Unicode 编码,一个字符占用 2 个字节

java.io 中最重要的就是 5 个类和一个接口:

  • 5个类指: File、字节流中的 OutputStream 和 InputStream、字符流中的 Writer 和 Reader ;

  • 一个接口是指 : Serializable

在 Android 中,文本文件/XML 等这些都是用 字符流来读写;而如 RAR、图片等非文本,则用字节流来读写。

二、Java IO 中的装饰模式

以一段容易让人费解的代码开始:

1
2
3
4
DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(
new FileOutputStream(
new File(file))));

这个代码看起来很乱,多重嵌套,其实要理解 IO ,也不难,难的是要更优地去使用 IO 。接下来抽丝剥茧地解释一下上述代码:

  1. new File() 就是创建一个 File 文件,这个没什么好说的

  2. FileOutputStream 这层就是将 File 转为字节流

  3. BufferedOutputStream 就是用来提升速度的,减少对磁头的调用,如果没有它,就会一个一个字节地去访问磁盘;有了它之后,就是一块一块地访问

  4. 而使用 DataOutputStream 就是保持数据的格式

我们在日常使用的时候,一般都要是优先考虑 DataOutputStream ,然后交给 BufferedOutputStream 提升效率。上面的例子就是典型的用法。

要理解输入输出流,要把自己当做电脑的 内存,站在内存的角度思考,这样 OutputStream 就是写出到磁盘上了,InputStream 就是读入到内存了。

为了能更好地理解,我们这里写一个 Demo:

1
2
3
4
5
6
7
8
9
10
11
private static void testDataOutputStream() throws Exception{
DataOutputStream out = new DataOutputStream(new BufferedOutputStream(
new FileOutputStream(new File("C:\\Users\\panda\\IdeaProjects\\JavaDemo\\src\\testtxt\\out.txt"))
));
out.writeInt(1);
out.writeBoolean(false);
out.writeByte((byte)0x41);
out.writeLong((long)0x98765432);

out.close();
}

我们能看到上述代码往文件中写入了东西,但是直接打开看起来是乱码的,这只是编码的问题,使用正确的编码就不会有问题。

还有一点要注意,使用 DataOutputStream 写入之后,通过 DataIntputStream 读出来的时候,需要保证顺序,比如上述例子里面先写入 int ,后写入 boolean ,读的时候也要先读 int 后读 boolean ,否则会错乱

序列化的时候,大量使用这个 IO 操作。

三、装饰器模式

在上述代码中,我们使用 FileOutputStream 也能满足需求,但是为了磁盘好我们又包装了 BufferedOutputStream ,此时也已经能用了,但是为了数据格式,我们又加了 DataOutputStream 。

这种整个一层一层封装就是我们常见的装饰模式,如果要对装饰模式理解深刻一点的话,可以用视频中老师举的例子: 人本身就是一个类(类似Android 中的 Context),但是人一般都穿内衣(),而内衣之外,一般都会穿日常衣服装饰自己。整个装饰器模式的结构如下图所示:

四、字节流与字符流的区别

在开发中到底使用字节流好还是使用字符流好呢?

首先,在硬盘上保存文件或者进行传输的时候都是以字节的形式进行的,包括图片也是按字节完成。字符只有到了内存中才会形成。所以,如果要使用 Java 程序实现拷贝功能的话,应该选用字节流进行操作,并且采用边读边写的方式(节省内存)。

五、字节流与字符流的转换

虽然 Java 支持字节流和字符流,但是有时候需要在二者之间进行转换, InputStreamReader 和 OutputStream 这 2 个类是字节流和字符流之间相互转换的类。

5.1 InputStreamReader

用于将字节流的字节码转成字符,其中一个构造 方法:

1
2
//用默认字符集创建一个 InputStreamReader 对象
InputStreamReader(InputStream in)

5.2 OutputStreamWriter

用于将写入的字符转成字节后写入一个字节流,其中一个构造方法:

1
2
//用默认字符集创建一个 OutputStreamWriter
OutputStreamWriter(OutputStream out)

5.3 BufferedWriter 和 BufferedReader

为了频繁转换字节流和字符流,对以上2个类进行了封装, 即 BufferedWriter类封装了OutputStreamWriter类;BufferedReader类封装了InputStreamReader类;示例代码如下:

1
2
3
4
5
6
7
BufferedWriter out=new BufferedWriter(new OutputStreamWriter(System.out));
BufferedReader in= new BufferedReader(new InputStreamReader(System.in);

//下面的语句可以从控制台读取一行字符串
BufferedReader in=new BufferedReader(new InputStreamReader(System.in));
String line=in.readLine();

一、序列化与二进制串

序列化其实就是将数据结构转换成二进制串的过程。这里的二进制串,在 Java 中很容易和 String 的概念混淆,实际上 String 也是一种特殊对象(Object),对于跨语言间的通信,序列化后的数据当然不能使某种语言的特殊数据类型。

二进制串在 Java 里面所指的应当是 byte[]

二、序列化/反序列化的目的

  • 数据的生命周期需要比 JVM 长。Java 允许我们在内存中创建可复用的对象,但是一般只有 JVM 存在的时候,这些对象才能存在,即对象生命周期不能比 JVM 更长。所以,如果需要停止 JVM 后数据还存在,就要序列化保存这些数据。

  • 序列化只是对变量而言。序列化对象的时候,只针对变量进行序列化,不针对方法进行序列化。

  • 需要永久保存或者在网络上传输的时候,需要序列化之后才能进行。

三、序列化常见的方案

  • Java 中有 Serializable

  • Android 中有 Parcelable

  • 还有 Json、xml 、Protocol Buffer 等

四、选择合理的序列化方案

  • 性能

  • 通用性

  • 鲁棒性

  • 可调式性/可读性

  • 可扩展/兼容性

  • 安全性/访问限制

五、Serializable

首先, Serializable 只是一个接口,为什么一个空的接口能够实现序列化?因为它只是一个标识、标记!

如果要保存到磁盘上,还需要使用 IO 流(ObjectInputStream/ObjectOutPutStream)来辅助,这个好理解。

六、Externalizable

要自己实现 writeExternal 和 readExternal 方法,但是使用的时候需要注意以下几点:

  • write 和 read 方法二者要对应,比如,write 的阶段没有只写入了一个 String 类型,但是读的时候 读了 String 还要读 int ,这时候就会导致崩溃报错。

  • write 和 read 的顺序要保持一致,比如先 write 了String ,再 write 了int ,读的时候如果先读int ,后读String ,也会导致报错

  • 要有默认无参的、public类型的构造函数,如果你自己写了有参数的构造函数,一定再加上无参的默认构造函数

为什么要有一个 public 无参的构造函数?

七、相关面试题

7.1 什么是 serialVersionUID ? 如果不定义会发生什么?

  1. serialVersionUID 是一个 private static final long 类型的 ID ,它通常是对象的 hashCode

  2. 它用于对象的版本控制,我们可以在类文件中指定这个值

  3. 如果不指定它的值,当你序列化类 A.java 生成了文件 a.txt ,之后,在 A 类中添加了一个字段 sex ,则在反序列化的时候会报错,提示版本不一致

  4. 还有,如果指定serialVersionUID 为 1,序列化 A 的时候生成了 a.txt ,这时候添加了字段 sex ,如果此时不改 serialVersionUID 的值,反序列化的时候,能成功,只是 sex 字段为 null(这里说的是对象类型,如果是 int 等类型就会是 0 ,后面一样)而已

  5. 接着上面,如果序列化了 A ,添加了 sex 字段,此时还将 serialVersionUID 改为 2 ,此时反序列化就会报错,同样是版本不一致

7.2 序列化的时候,如果某些成员不要序列化,该怎么实现?

使用 transient 关键字。加了 这个关键字的对象 sex ,在反序列化的时候会为null(如果是int 等类型就会是 0) 。

7.3 如果类 A 中有成员未实现可序列化接口,会发生什么?

如果 A 中有成员 User 对象没有实现可序列化接口,序列化的时候会报 “不可序列化”的异常。

7.4 如果类 A 是可序列化的,但是其父类不是,则反序列化后,从父类继承过来的实例变量状态如何?

假如 User 继承了 Person 类,并且实现了 Serializable 接口,但是 Person 类没有实现 Serializable 接口。

即 父类的成员未默认值,Object 类型为 null ,int 等类型为 0。

如果要想父类也实现,需要父类也实现 Serializable 接口,并且有默认的构造函数(无参的,public 的)。

一、JVM 中常见的垃圾回收器

一般来说的话,年轻代占整个堆空间的 1/3 ,而老年代占整个堆空间的的 2/3 。

但是对于 Parallel Scavenge/Parallel Old 这对组合(这种组合目前的吞吐量最大,前者回收年轻代,后者回收老年代,从名字也能看出来)垃圾收集器而言,之前说的一些内容可能不适用了:

  • 堆的大小是会动态变化的,不是一成不变的,比如最开始是 200 ,后来空间不够,GC 过后被扩容到 300M ;如果发现空闲堆太多,可能就缩小到 100M 了;

  • 年轻代不一定占堆空间的 1/3 了,年轻代和老年代的占比也可能变化

  • 年轻代里面也可能不是 8:1:1 的结构了

一般来说,平时建议堆的最大大小和最小大小设置同一个值,不要让空间时不时变化。

二、响应优先的垃圾回收器

后来,大家的需求变成响应优先CMS (Concurrent Mark Sweep) 标记清除 垃圾回收器就是这个跨时代的垃圾回收器。 它是单独针对老年代的垃圾收集器。导致卡顿的一般是老年代的 GC 操作。它是怎么做到响应时间最短呢?步骤如下:

  1. 初始标记——GC Roots 的数量是决定性因素

  2. 并发标记

  3. 重新标记

  4. 并发清除

为什么能响应时间最短,那是因为把耗时的标记和清除操作都能并发了!

为什么能做到并发清理?因为标记-清除可以做到并发,因为我并不关心垃圾,我只关心与 GC Roots 有连接的部分。所以垃圾被覆盖啊之类的都是可以的,所以我能够不暂停而去做清理操作

三、CMS 的预清理

预清理主要往 2 个方面努力:

  • 在并发标记的阶段,如果 Eden 区有 A 对象引用到 老年代中与GC Roots没有连接的 B 对象(会被老年代里面视为垃圾),就要把 B 标记为GC Roots 可达的点。这个操作本来是放在重新标记阶段,现在放在并发标记阶段。这样,减少了重新标记 STW 的时间。

  • 并发标记阶段,如果老年代内部引用发生变化(之前不可达的变为可达的了),建一个类似于卡表结构,后续的重新标记阶段就无需考虑这个区域了

处理 From 和 to 区(那 2 个survivor 区域的名字)的对象 , 到老年代可达,导致老年代的并发标记中的引用变化。并发可中断预处理的过程就是 :

1
2
3
4
while(xxx){
1、处理 from 和 to 区的对象到老年代可达,导致的老年代并发标记中的引用变化
2、老年代内部的引用变化,记录在一张表中
}

这样,在重新标记就可能只需要扫描部分区域即可。

退出循环的部分条件:

  • 时间控制,达到一个时间之后自动停止

  • Eden 区的内存使用达到设定的比例,如果比例太高的话,你这个并发预处理没什么作用

都是为了让一些在重新标记做的事情,可以放到并发标记阶段,减少停留时间

预处理是做一次,扫到就扫到了,没扫到就算了;而 并发可中断预处理 会在 while 中一遍一遍地轮,多次扫描。

CMS 中的问题:

  • CPU 敏感:由于有多线程

  • 浮动垃圾:由于是多线程,所以会有一些清理不到,比如你边扫地别人边丢垃圾

  • 内存碎片:CMS 本质还是标记-清理算法

四、JVM 的调优技巧

如果需要确定总堆的大小,可以用堆空间的活跃数据来做,比如,JVM 运行了一个星期之后,一般就能得到其活跃区间的大小了,一般总堆以及 年轻代、老年代的设置规则如下:

空间 倍数
总堆大小 3~4倍活跃数据大小
年轻代 1~1.5倍活跃数据大小
老年代 2~3倍活跃数据大小
永久代/元空间 1.2~1.5倍Full GC 后的永久代空间占用

4.1 拓展-增大年轻代空间能不能提高 GC 效率?

答案: 可以。因为如下原因:

  • GC 时间间隔会增大。扩容之后,一般扫描的时间间隔会增加,比如以前是 500ms ,后续会变为 1000ms。

  • 放在年轻代回收比放在老年代回收好,因为新生代的复制算法效率比较高。未增大之前,可能过两轮对象就进入老年代了(大对象直接进入、或者说某一个年龄超过一半了,等等都是会进入老年代的,不一定得年龄到了);但是增大之后,由于新生代对象的朝生夕死的特性,还没挨到下一次GC,对象就已经不可达了,进入不了老年代

  • 扫描判断对象是否存活的耗时比复制存活对象的耗时少。年轻代 gc 消耗的时间是: 扫描对象(耗时 T1) 、复制存活的对象到幸存区(T2),那么总时间是 T1 + T2 。那么想想,当我们扩容成 2 倍的时候,是什么情况?扫描时间应该是变成了 2 * T2 了,但是存活对象的复制过程呢?由于GC间隔时间拉长,很多对象被回收了,复制存活对象的耗时一般会比 2 * T2 要小。

4.2 JVM 如何如何避免 Minor GC 时扫描全堆的?

前面我们说不同的代有不同的垃圾回收器,他们的算法是一样的,如下图所示:

那么,当跨代引用的时候,是如何做到无需扫描全堆的呢?举个例子,假如你要回收年轻代,我们老年代里面有个对象 A,引用了年轻代中的一个对象 B,那么老年代的 A 会被作为 GC Roots ,那么你要确定这个根,你要在老年代里面去找根?

所以如果有跨代,那么我们是否要进行全堆的扫描(不然没法知道GC Roots 啊,虽然你说上述情况可以作为 GC Roots)?答案是不用的,因为 JVM 可能会维护有卡表结构,它标记了所有的对象。未跨代的时候,是某个标记,假设说是 0 ;如果有跨代引用,在卡表里面就标记成另一个了,假设是 0 。

4.3 常量池(方法区)

常量池一般有 3 种,分别如下:

  • Class 常量池:.class 文件中带的,编译时的字面量、引用。

  • 运行时常量池:运行时如果用到的某个类A ,如果 ClassLoader 还未加载这个类 A ,那么就会是一个符号引用,这是个常量,到运行时符号引用转为直接引用。

  • 字符串常量池:规范里面是没有的。与 String 的设计思想有关系,String 对象不可变(类被final,value数组也是final)

new String(“abc”) ,已经有字面量 “abc” 了,在编译的 时候就会被放入常量池里面,并且在堆里面会创建一个 String 对象,值指向常量池的那个 “abc” 。

“ab” + “cd” +”ef” 这种,编译器在编译的时候就会变成 “abcdef” 直接给优化了

如果发现如下这种循环次数比较多的字符串相加,编译器也会优化:

1
2
3
4
5
String str = "ab";

for(int i = 0;i<100;i++) {
str += arr[i];
}

变为使用 StringBuilder 来实现。

一、JVM 与 Dalvik

JVM 与 Dalvik 实现不同:

  • JVM 是基于栈的虚拟机:每个运行时的线程都有一个独立的栈,每一次方法的调用都会往栈里面压入一个栈帧,最顶部的是当前栈帧,代表当前执行的方法。基于栈的虚拟机通过操作数栈进行所有操作。

  • Dalvik 是基于寄存器的虚拟机:相当于将操作数栈和局部变量表合并成了虚拟寄存器

对于如下同一段代码:

1
2
3
4
5
public class Demo {
int a = 1;
int b = 2;
int c = a + b;
}

二者运行过程差异如下图所示:

二者差异

上图是JVM 运行过程

Dalvik的结构

上图是 Dalvik 的运行过程。

二、Dalvik 和 Art 区别

Android Dalvik 最开始是解释执行,执行的是 dex 字节码,需要经过虚拟机翻译,然后才能被机器执行。而从 2.2 开始,支持 JIT 即时编译(Just In Time),运行过程中将热点代码直接编译成机器码,后续就省略了翻译过程!因此效率更高,但也因此跨平台不行。

Android 5.0 最开始 ART 虚拟机执行的是本地机器码,但是 Dex 里面还是字节码。所以这个机器码从哪里来?原因是 安装做了优化,做了 AOT (Ahead Of Time)提前编译,做了 dex2oat 的优化,所以安装也慢(5.0 、6.0)。但是从 7.0 以后,又没那么慢了,变成混编了,混合了 AOT、解释 以及 JIT 这三种方式了。在运行过程中解释执行,对热点代码进行 JIT ,并且经过 JIT 编译的方法(这是临时的)记录到配置文件中。当设备空闲和充电的时候,编译守护进程会运行,根据 Profile 文件对常用代码进行 AOT 编译,下次就可以直接运行使用,而不用再从dex 文件中找了。

对比:

栈式 vs 寄存器式 对比
指令条数 栈式 > 寄存器式
移植性 栈式优于寄存器式
代码尺寸 栈式 < 寄存器式
指令优化 栈式更不易优化
解释器执行速度 栈式解释器速度稍慢
代码生成难度 栈式简单
简单实现中数据移动次数 栈式移动次数多

三、ClassLoder

  • BootClassLoader : 加载Android FrameWork 中的类,比如 String、Activity 等

  • PathClassLoader: 我们程序的 ClassLoader ,我们引入的第三方库、写的代码等

Android 都是自己写了这些 ClassLoader ,没有使用 Java 之前的那些,这个也能理解,原来Java 自己去加载类的时候,是加载 .class 文件;而 Android中是加载 .dex 文件了

ClassLoader 在 load 一个类的时候,都是需要 IO 操作的(从磁盘把文件读进来),然后按照一定格式解析(类似Json 一样),所以,这个 load 这个操作一定是个耗时操作。

注意,ClassLoader 做双亲委派机制,其中的 parent 不是指当前类的父类,这个 parent 只是 ClassLoader 中的一个成员变量而已。所以应该叫做父加载器更好点

比如,PathClassLoader 的父加载器是 BootClassLoder ,但是它的父类是 BaseDexClassLoader

3.1 为什么双亲委托机制

  • 避免重复。被父加载器加载过了,自己就不用加载了

  • 安全: 防止核心API 库被随意篡改。

    比如说你自己创建个 java.lang.String 类,如果没有双亲委派机制,那么你加载了你自己的 String 类,要是有崩溃,那么其他类基本上都会受影响,String 使用太广泛了。所以,双亲委派反正都是父ClassLoader 去加载,会导致都是同一个,不会有歧义。

3.2 PathClassLoader

里面可以有个 dexPath 参数,初看起来只能传入一个 dex 的路径,其实是可以传入多个的,多个路径以 冒号 (:) 分割即可。之后,为每个路径的dex 文件生成一个 Element 元素,最后形成 一个 dexElements 数组。

ClassLoader 中有个 DexPathList????存疑,自己去看下

四、热修复

修复了 Demo.java 这个类,之后打包成 dex 文件,然后想办法将其插入到 dexElements 数组的最前面,这样,找 Demo.java 这个类的时候,从 dexElements 数组从前往后找,先找到修复后的 Demo.java ,后面的有bug 的 Demo.java 就会不管了。这样就能实现热修复了。

可以在 Application 的第一个方法中插入新的 dex ,要保证需要修复的类没有被加载过。因为被加载过就有这个类的缓存了,就不会从 dex 文件中去找了。

4.1 将补丁 dex 插入到 dexElements 数组最前面

这里肯定需要用到反射,具体步骤如下:

  1. 获取到当前应用的 PathClassLoader

  2. 反射获取到它的属性对象 pathList

  3. 反射修改 pathList 的 dexElements ,这里又分为 3 个步骤

    1. 把补丁包 patch.dex 转化为 Element[] 数组

    2. 获得 pathList 的 dexElements 属性 dexElements

    3. 将上述新生成的 Element 数组,与老的 dexElements 合并生成新的数组 newPatchElements ,之后将这个 newPatchElements 赋值给上述的 pathList 的成员变量

怎么把单个的类打包成 dex ?在 build-tools 目录下,使用里面的 dx 来打包,命令如下:

dx –dex –output=output.dex /packagename/A.class

4.2 我们为什么不能修改系统的类,只能修改自己的类呢?

这是因为双亲委派机制,系统的类(比如 String 类)都是通过 BootClassLoader 去加载的,而我们的类是 PathClassLoader 加载的。并且 PathClassLoader 的父加载器是 BootClassLoader !

五、答疑环节

前面讲到Android N 的时候,会有一些代码变成 机器码了,所以,这时候我们用上述的方法,是不能完成热修复的。那咋办?Tinker 自己创建了一个 ClassLoader ,这样系统就不会使用系统给的 ClassLoader ,缓存的机器码自然也就不存在了,还是会去找dex去执行。

作业:自己实现一个 热修复的代码Demo。,补丁包直接放在 sd 卡里面即可。

这里需要注意在 Android 10.0 的时候,对存储权限有格外要求,你可以放在私有目录

一、 JVM 中对象的创建过程

对象的整体创建过程如下所示:

对象创建过程

我们的常量池里面可能存在符号引用,什么是符号引用?我们引用了一个对象,但是不知道这个对象的真实地址。举个例子:你引用了 com.esun.B ,如果是直接引用的话,那就是真实的地址。 如果你在 A 的常量池里面引用了 B ,但是 B 还没有加载进来,这时候你就只能用一个符号。

在检查加载的过程会去检验是否能正常将符号引用转换为直接引用。

1.1 内存分配

内存分配一般有 2 种方式:

  • 指针碰撞。内存比较规整,前面内存已经分配到 7 了,那么这次分配从 8 开始

  • 空闲列表。不在乎内存碎片,根据维护的空闲列表来分配

分配的时候需要注意多线程安全,主要有 2 种方式:

  • CAS + 失败重试操作

  • 本地线程分配缓冲:给每个线程在 Eden 区默认划分一块空间,线程分配的时候,就在那块空间去操作,由于线程有自己专属的,所以并不会有线程安全问题

1.2 对象内存布局

一个对象会包括对象头、实例数据、以及对齐填充(不一定有)。其中对象头很重要,它主要包括:

  • Markword

  • 类型指针,指向方法区中的 Class ,标明它是哪个类型

  • 记录数组长度(如果是数组对象才有)

二、GC

2.1 判断对象存活

有 2 种主流的方式:

  • 引用计数,不能解决循环引用——Python 就是用这种方案

  • 可达性分析,也说 根可达方法

GCRoots 主要有4种,但是不止4种:

  • 静态变量

  • 线程栈变量

  • 常量池

  • JNI (指针)

  • 如果还纠结,就有其他:Class对象、类加载器、

方法区中的 class 对象是否能被回收?肯定能被回收,有一个很重要的条件是 new 出的所有对象都被回收了。其他的还包括它的 ClassLoader 也被回收了等等。

真正要宣布一个对象可回收需要 2 次标记,首先是不可达,其次就是 finalize ,所以不可达不是非死不可的条件。 finalize 里面可以让引用继续来。 DirectByteBuffer 直接内存分配,它里面也用了虚引用,是否用来协助内存回收??待确认

2.2 对象分配策略

对象的分配原则:

  • 对象优先在 Eden 分配

  • 空间分配担保

  • 大对象直接进入老年代

  • 长期存活的对象进入老年代

  • 动态年龄判定

我们说几乎所有的对象都在堆上分配,为什么是几乎?因为还有栈上分配这种方案:

  • 触发了 JIT (热点数据),逃逸分析的时候发现对象不会逃出当前方法和线程,的时候就可能触发栈上分配

2.3 GC 详情

堆分代:

  • 新生代: Eden区、s0、s1 区,三者大小 8:1:1 ?为什么,因为大多数对象都是朝生夕死的,存活时间很短

  • 老年代,一般老年代 GC ,就会触发 Full GC: 也就是年轻代、老年代、方法区都 GC

新生代一般是用复制算法,老年代是标记整理、标记清除算法

标记整理算法都是怎么做呢? 其实是: 标记——>整理——> 清除 ,为什么要这么做呢?(有些人会以为是:标记——>清除——> 整理 ),这是因为这里的时候,可以覆盖那些垃圾,等整理完了之后,垃圾可能会变少了,清除也快一些。

这个过程会有STW 的,暂停所有线程,这是为什么?因为整理的时候,对象的位置会发生变化啊,整理之后,以前在位置 15 的时候变成 位置 6 了,所以要停止。

三、虚拟机的优化技术

  • 热点数据编译成本地代码

  • 本地线程分配缓冲

最终, JVM 的学习一张图看了:

JVM一张图

一、Java 程序执行过程

Java执行过程

二、JVM 规范

Java 在 JVM 上经过 ClassLoader 加载后,有 2 种执行方式:

  • 字节码解释器解释执行。Java 先编译成 class 文件,再由 C++ 解释器

  • JIT执行:HotSpot 就这样执行,将Java 代码翻译成汇编等形式的机器码,速度较快

JVM 是 C++ 写的,它解释执行是怎么解释呢?它碰到你 Java 代码中的 new 关键字的时候,它就执行某一段 C ++ 代码,类似:

1
2
3
if(碰到了new) {

}

也就是通过 C++ 来翻译 Java 语言。这经过了一道转换,效率是有点低的。

JIT 执行,直接将Java 代码翻译成汇编代码等机器代码,就能迅速执行。不过一般只有热点代码才这样做。缺点是,你要先提前翻译,编译的时间会比较长。

2.1 运行时数据区域

直接内存:没有经过 JVM 的虚拟化。比如总共有 8G 的存储空间,JVM 虚拟化了 6G ,但是还剩下 2G 我们没法去通过 JVM 使用,这时候就能用直接内存去使用这 2G 空间。

优点是不受 JVM 管理,不会受到 GC 影响,毕竟 GC 会Stop The World;缺点同样明显,就是使用不方便,自己申请,用完要记得释放,否则造成内存泄漏。

虚拟机栈的大小在不同的平台上的大小是不同的,在 Linux 上一般是 1M 。如果是死递归,不断往虚拟机栈压入栈帧,讲道理会将这个栈空间耗尽,不过现实中往往先 StackOverFlow。

一个线程光这个虚拟机栈就占了 1M 的空间,如果有500 个线程,那么占用的空间也是非常可观的,所以我们也要控制线程数量。

每个栈帧里面会包含什么呢? 一般是:

  • 局部变量表

  • 操作数栈

  • 动态链接

  • 完成出口

可能这些概念不太好懂,举个例子,看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Person {
public int work() throws Exception{
int x = 1;
int y = 2;
int z = (x+y) * 10;
return z;
}

public static void main(String[] args) throws Exception {
Person person = new Person();
person.work();
person.hashCode();
}
}

上述代码在执行 main 方法的时候,会为 main 线程创建一个虚拟机栈,调用一个方法就会创建一个栈帧,所以 main 方法会有一个栈帧,调用 person.work 的时候也会创建一个栈帧。整个过程大体上会有如下操作:

  1. x 和 y 都被定义了,但是还没使用,所以压入局部变量表中

  2. 执行 (x+y) * 10 的操作时,先计算 x + y = 3, 将 3 这个临时值放入操作数栈

  3. 之后,将操作数 10 也压入操作数栈的栈顶

  4. 接着, 操作数栈中的 3 和 10 都出栈,相乘得到结果 30 ,再压入 操作数栈

  5. 因为这里定义是 z 局部变量接受这个结果,所以需要将操作数栈中的 30 出栈,在局部变量表中放入 z 这个值 30

  6. 最后要返回 z ,方法与方法之间操作,这个返回值相当于一个操作数,所以又要将 z 这个 30 值复制到 操作数栈中

  7. 最后,带着 work 方法栈帧中操作数栈的这个 30 ,返回了 main 方法的 栈帧。

这里有个点需要注意下,构造方法也是方法,所以也会有栈帧

这个过程一直伴随着程序计数器的不断移动。至于完成出口,是记录调用方执行到哪里之后再来调用当前方法的,上述main 方法中 ,假如 是main 方法自己执行到行号 2 的时候就调用 person.work 了,此时,work 方法中记录的程序出口就是这个行号2,work 方法执行完,切回main 方法时, 会把这个值带回去,这样便知道从哪里继续执行。来看一下下面的这张图,这是字节码:

字节码示意图

左边的就是 0,1,2…12 ,这些代表行号,也代表字节码的偏移量,有些指令比较短,有些比较长,会占据不止一行,所以,行号可能会跳过,上面的图中没有 8 这个行号,就是因为 bipush 指令比较大;右边的代表操作。非静态方法的局部变量表的第 0 个位置存的是 this ,也就是当前的对象。

动态链接 主要是跟多态有关。本地方法栈和虚拟机栈是可以合并的, HotSpot 就是这么做的。

三、课堂问题

课程中 demo 搞出来个 stackoverflow ,这个值得注意:

1
2
3
4
5
6
7
public class Person {
Person haha = new Person();

public static void main(String[] args) throws Exception {
Person wo = new Person();
}
}

因为在 main 方法中 wo 来new Person 的对象时, 就会触发 haha 的又来 new 一个 new Person 对象,就会陷入死循环了。

四、直接内存

如果想使用直接内存,可以通过 ByteBuffer 来实现,这样可以被回收:

1
2
3
public static void main(String[] args) throws Exception {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(3 * 1024 * 1024);
}

ByteBuffer 是使用 unsafe 来实现的。

五、工具

使用 HSDB 查看 JVM 中的各个对象,能通过指定类和里面的对象,就能看到。

注解的作用: 用于标记,接口写成 interface ,注解 的写法是 @interface 。

一、元注解

元注解,我们用得比较多的是下面 2 种:

  • @Target : 针对哪些地方来作用这个注解,有 TYPE (作用在类、接口、枚举,甚至注解上等)、METHOD(方法)、FIELD (属性)、PARAMETER(参数)。 Target 注解的value 是个数组,可以多个的

  • @Retention : 表示可以将注解信息保存到什么层次。有 SOURCE(源码)、CLASS(.class文件)、RUNTIME(虚拟机级别)

Retention 中三种级别的比较:

级别 典型技术 使用场景 例子
SOURCE APT(annotion processor tool:注解处理工具) 编译期获取注解与其成员信息,一般用于生成辅助类 InDef、StringDef,参数只接受指定的几种值
CLASS 字节码增强、插桩 编译出class文件后,对class修改
RUNTIME 反射 运行期间,反射获取注解与其元素

注意:对于 Android 而言,打包 dex 的时候,CLASS 级别的注解都会被抛弃掉,但是 RUNTIME 级别的会保留

IntDef 的实现原理:传参非指定的时,会报红,看着是报错,但是是不影响运行的!它的原理就是 lint 检测

二、自定义 APT (annotion processor tool)

我们可以自定义注解处理器,自定义的类继承 AbstractProcessor ,然后注册。

自定义的注解处理器不会被编译到代码中,只是编译的时候使用。这里没有详细写明怎么去自定义,如果后续用到了,可以再去看视频

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//利用 APT 新增一个类
public class APTTest extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
String code = "public class A {\n" +
"}";
Filer filer = processingEnv.getFiler();
OutputStream outputStream = null;
try {
//为什么用 JavaFileObject?因为它能默认将生成文件放到 build 目录下,无需你手动去加路径
JavaFileObject sourceFile = filer.createClassFile("A");
outputStream = sourceFile.openOutputStream();
//将代码写入文件
outputStream.write(code.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (outputStream != null) {
outputStream.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return false;
}
}

javac 的执行流程如下:

javac的执行流程

发现注解之后,执行注解处理器,因为你在注解处理器里面可能会做改变(或者能新增了Java文件),因此能看到注解处理之后会有一条线回去,重新开始处理下。所以,注解处理器中的 process 方法可能会执行多次(因为新生成的类中可能还包括注解,所以可能是非常多的次数)。

因为 process 方法会执行多次,所以我们必须要判断注解过程是否执行完了,如果已经执行完了,就不要再去生成文件 A 了,可以通过如下方法判断过程执行完:

1
2
3
annotations.isEmpty()

roundEnv.processingOver()

三、字节码插桩技术

为什么字节码插桩?因为你还是 Java 代码的时候,可能没有这种条件去做这个事情。比如,组件 A 中需要 new 出 B 组件中的 一个类,由于大家都是互相独立的,没有引用,但是 字节码中就不一样了,所有的类都变成了 class 了,肯定有引用的。典型的如 ARouter 中的 路由表的实现。

还有个典型的例子是 腾讯的 Matrix 性能监控的实现

字节码插桩也能用来实现 AOP ,比如 360 的 APM ,这样就能实现在代码中的判断。

字节码插桩的本质: .class 是个文件,它有自己的格式,可以作为读入,按照一定的格式修改,再保存就可以了

四、反射

我们能够通过反射修改 final 类型的成员的值吗?答案是肯定的,但是肯定有人经历过下面这种情景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ReflectTest {

public final int a = 1;

public int getA(){
return a;
}

public static void main(String[] args) throws Exception {
ReflectTest reflectTest = new ReflectTest();
Field a = ReflectTest.class.getDeclaredField("a");
a.setAccessible(true);
a.set(reflectTest, 2);

System.out.println("a = " + reflectTest.a);
}
}

通过上面的代码反射修改 a 的值为 2,但是最终 println 打印出来的却是 1 ,这是没有修改?其实不是,这是因为 Java 编译过程的优化导致的,这里是内联,我们看下它编译成的字节码,反编译过来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ReflectTest {
public final int a = 1;

public ReflectTest() {
}

public int getA() {
return 1;
}

public static void main(String[] args) throws Exception {
ReflectTest reflectTest = new ReflectTest();
Field a = ReflectTest.class.getDeclaredField("a");
a.setAccessible(true);
a.set(reflectTest, 2);
PrintStream var10000 = System.out;
Objects.requireNonNull(reflectTest);
var10000.println("a = " + 1);
}
}

看到代码最后一行,我们的 reflectTest.a 已经直接 变成 1 了,并且注意看 getA() 方法 ,它里面return a 也直接变为 return 1 。所以,我们可以知道,其实 a 的值是已经改了,但是通过上述的代码看不出来。

那么问题来了,如果我想获取到这个修改后的 a 值怎么办? 答案还是通过反射获取,将最后一行的打印改成这样就行,就能正常输出修改后的 2 值了:

1
System.out.println("a = " + a.getInt(reflectTest));

4.1 反射调用方法为什么耗时

比如通过对象普通调用方法,类似 object.method() , 字节码中用 iconst_1 指令即可完成,不需要做额外事情,但是对于反射获取而言

  • 但是反射,我们调用的的是 Object.invoke (Object obj, Object…args) 能看出来,参数是 变长 的了,并且还都是 Object ,意味着你普通方法是 int 型 的话,这里需要装箱成 Integer类型,再包装成 Object 数组,在执行的时候又会把数组拆开,并且拆箱为基本数据类型。

  • 从源码可知,反射需要遍历所有的方法,匹配方法名和参数,然后才能得到正确的目标方法

  • 反射时需要检查方法的可见性,以及参数的匹配性

  • 反射时,编译期无法对动态调用的代码做优化,比如 内联

    比如,上面我们更改 a 的值为 2 ,通过反射时就能获取这个值,说明是没有做优化的

当然,现在硬件性能已经非常强了,反射能带来的影响还是比较小的,。

五、动态代理

动态代理的原理是,会自动给你生成代码,相当于修改字节码。自己去看下编译期给自动生成的那个 Proxy 类。

动态代理的原理还不太清楚,下次来需要着重梳理

作业

注解+反射+ 动态代理实现 @Click 方法,动态代理为了开闭原则,如下:

1
2
3
4
@Click(R.id.button)
public void click(View view){

}