0%

1、线程同步,你了解哪几种方式?

点击看答案
  1. 数据同步:可以使用Android 提供的同步容器。如: CopyOnWriteArrayList(当某个线程要修改list的元素时,首先copy一份出来,然后在修改前加锁,防止多个线程同时修改而copy多个副本,读的时候无需加锁,读的是副本)、concurrentHashMap(分段锁)、BlockingQueue(队列满了,在调用put,会阻塞,直到不再是满的;同理,取也是一样)。
  2. 用锁。同步锁:synchronize(无论synchronized关键字加在方法上还是对象上,他锁的都是对象,而不是把一段代码或函数当作锁)、ReentrantLock

2、synchronized 与 Lock。讲下 RecentLock 可重入锁? 什么是可重入锁?为什么要设计可重入锁?

点击看答案

介绍:synchronized:无论synchronized关键字加在方法上还是对象上,他锁的都是对象,而不是把一段代码或函数当作锁。
Lock:一般使用ReentrantLock类做为锁,需要显式指出加锁与释放锁的位置,在加锁和解锁处通过lock()和unlock()显示指出,所以一般会在finally块中写unlock()以防死锁。

区别:

  • synchronized 是个关键字,而 Lock 是个接口
  • synchronized 使用简单,会自动释放;Lock需要显式加锁与释放,要防止死锁
  • synchronized是悲观锁,其他线程只能阻塞来等待线程释放锁;Lock是乐观锁。
  • lock可以使我们多线程交互变得方便,而使用synchronized则无法做到这点。如:某个线程在等待锁的过程中需要中断、或者获知某个线程有没有获取到锁、或者需要指定notify哪个线程

3、为什么wait、notify、notifyAll 方法在object 类中,而sleep 在Thread 类中?

点击看答案

一个例子,假如要做的事情是“生火-烧水-煮面”。sleep 就是,当我生火之后,觉得有点累,要休息一段时间,所以并不立即烧水,等过会再烧,休息的这段时间是由我自己来控制的,这个灶台我也要一直占用;而对于wait,首先,wait 是由某个object 来调用的,这个object 类似于监督人的角色,当我点火在烧水的过程中,监督人要求我停下来,不允许我继续烧水了,同时剥夺我灶台使用权,让其他人先用灶台,我在旁边等着,直到这个监督人通知(notify/notifyAll)我可以继续使用灶台了,我才能继续。

通过这个例子,我们可以知道:

  • 首先,sleep 和 其他的是有本质区别的:sleep 是一个线程的运行状态控制,所以交给Thread 自己更合适;而wait 是线程之间的通讯工具,交给object更合适,这样各个竞争线程不需要知道彼此的存在。
  • 其次,wait、notify与notifyAll 是锁级别的操作,而锁属于对象,每个对象都可能作为锁,所以它们定义在Object 类中。

从另一个角度来说是,假如不这样做的话,即如果wait、notify和notifyAll 都在Thread 中,会有什么问题:

首先,wait 方法仍然可以使当前线程挂起,但是挂起后怎么被其他线程唤起呢?因为唤起时需要知道要唤起哪个线程。
其次,notify 与 notifyAll 都需要知道目前需要唤醒哪些线程。

当然你可以说我们使用共享变量或者其他方式,这无疑会增加线程间通信的复杂性,并带来安全隐患,所以并没有必要。

值得注意的是:必须在某个对象的同步方法或同步代码块中才能调用该对象的wait()、notify()或notifyAll()方法。原因很简单,如果这个代码块或者方法不是同步的,那么进入其中必然是不需要获取锁的,所以释放锁和等待锁就无从谈起,这时候调用的话会报 IllegalMonitorStateException。Java 给报这个错,其实就是不想让我们的程序在不经意间出现 Lost Wake-Up 问题。

以上内容参考csdn的这篇文章

4、延伸-深究 notify/wait 方法为什么一定要放在同步块中?

点击看答案

前面谈到的是没有同步就没必要释放或者等待锁,这里深究如果不放在同步块中会出现什么问题。这主要是涉及到 “Lost Wake-Up Problem”问题。

我们notify/wait 很典型的一个用途就是生产者-消费者 场景,伪代码如下:

生产者:

count ++;
notify();

消费者:

while(count <= 0) wait();
count –;

生产者和消费着都有两个步骤,如果不在同步块中的话,多线程情况下执行顺序很难保证,有可能会出现如下图所示的执行顺序:

生产消费乱序

初始的时候,count == 0,消费者发现条件成立,此时,发生上下文切换,生产者线程一顿操作执行了 count ++,之后发出了通知准备唤醒一个线程,这时候消费者刚决定wait,但是还没进入wait,此时,由于消费者线程还未进入wait状态,因此在等待队列中还找不到消费者线程,这个notify就被丢掉了。

以上内容参考自360linker的博客

5、两个线程交替打印,一个输出偶数,一个输出奇数

点击看答案

第一种,使用synchronize 关键字,锁住对象,每次只能一个线程进入。每打印一个数,就释放锁,然后挂起自己,如此往复:

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
30
31
32
33
34
35
36
37
public class MainClass {

public static void main(String[] args) {
MainClass obj = new MainClass();

Thread t1 = new Thread(obj::printOdds);
Thread t2 = new Thread(obj::printEven);

t2.start();
t1.start();

}

public synchronized void printOdds() {
for (int i = 1; i <= 100; i += 2) {
System.out.println("t1---" + i);
notify();
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public synchronized void printEven() {
for (int i = 0; i <= 100; i += 2) {
System.out.println("t2---" + i);
this.notify();
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

第二种,使用volatile 来保证:

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
30
31
32
33
34
35
36
37
public class MainClass {

static volatile int num = 0;
static volatile boolean flag = false;

public static void main(String[] args) {

Thread t1 = new Thread(){
@Override
public void run() {
super.run();
for (;num<=100;){
if (!flag && (num == 0 || ++num %2 == 0)){//打印偶数
System.out.println(num);
flag = true;
}
}
}
};

Thread t2 = new Thread(){
@Override
public void run() {
super.run();
for (;num<100;){
if (flag && ++num %2 != 0){//打印奇数
System.out.println(num);
flag = false;
}
}
}
};

t1.start();
t2.start();
}
}

上述代码中 num 和flag 都必须为volatile ,根据短路机制,if 语句中基本上只需要管 flag的值,所以即使 ++num 这个操作是非原子性的,也无妨。

以上内容参考莫那.鲁道的博客

如何控制某个方法允许并发访问线程的个数?

6、Java创建线程的三种方式

点击看答案
  • 继承Thread类。优点:使用简单 缺点:已经继承Thread 类,不能继承其它类。
  • 实现runnable 接口,创建线程类。
  • 通过Callable 和 Future 创建线程。

以上内容参考自:Java创建线程的三种方式

7、synchronized原理

点击看答案

JVM基于进入和退出Monitor对象来实现 代码块同步 和 方法同步 ,两者实现细节不同。

代码块同步: 在编译后通过将monitorenter指令插入到同步代码块的开始处,将monitorexit指令插入到方法结束处和异常处,通过反编译字节码可以观察到。任何一个对象都有一个monitor与之关联,线程执行monitorenter指令时,会尝试获取对象对应的monitor的所有权,即尝试获得对象的锁。

方法同步: synchronized方法在method_info结构有ACC_synchronized标记,线程执行时会识别该标记,获取对应的锁,实现方法同步。

两者虽然实现细节不同,但本质上都是对一个对象的监视器(monitor)的获取。任意一个对象都拥有自己的监视器,当同步代码块或同步方法时,执行方法的线程必须先获得该对象的监视器才能进入同步块或同步方法,没有获取到监视器的线程将会被阻塞,并进入同步队列,状态变为BLOCKED。当成功获取监视器的线程释放了锁后,会唤醒阻塞在同步队列的线程,使其重新尝试对监视器的获取。

对象、监视器、同步队列和执行线程间的关系

以上内容参考自:synchronized原理

8、线程间通信?

点击看答案

1、通过内存共享,共享内存中的信息是公共可见的,但是需要显式地进行同步,可以使用Synchronize 和 Lock 来进行同步。

2、notify/wait 方法

3、Condition 实现等待/通知

以上内容部分参考自: 线程通信

9、单例中,为什么要使用volatile 修饰instance?

点击看答案

主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情:

1.给 instance 分配内存
2.调用 Singleton 的构造函数来初始化成员变量
3.将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)。

但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。

10、有三个线程T1,T2,T3,怎么确保它们按顺序执行?

点击看答案

参考2个线程的即可:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class MainClass1 {

static volatile int num = 0;
static volatile int flag = 1;

public static void main(String[] args) {

Thread t1 = new Thread(){
@Override
public void run() {
super.run();
for (;num<=100;){
if (flag == 1){
++num;
System.out.println("t1-"+ num);
flag = 2;
}
}
}
};

Thread t2 = new Thread(){
@Override
public void run() {
super.run();
for (;num<100;){
if (flag == 2){
++num;
System.out.println("t2-"+ num);
flag = 3;
}
}
}
};

Thread t3 = new Thread(){
@Override
public void run() {
super.run();
for (;num<100;){
if (flag == 3){
++num;
System.out.println("t3-"+ num);

flag = 1;
}
}
}
};

t1.start();
t2.start();
t3.start();
}
}

11、Java停止一个正在运行的线程

点击看答案
  • 采用经过volatile 标记的变量来控制
  • Thread.stop() ,但是不推荐
  • 使用FutureTash 时,可以使用其 cancel() 方法来取消任务
  • 使用return 关键字
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
new Thread(){
@Override
public void run() {
super.run();

System.out.println("开始执行");
if (true){
return;
}

System.out.println("return 之后");
}
}.start();

}

代码只会打印 “开始执行” ,代码中if是需要的,不然会提示编译不通过。

以上部分内容参考自:Java如何停止线程

12、Java线程池工作原理

点击看答案

线程池好处

  • 复用已经创建的线程,降低线程创建和销毁带来的损耗
  • 提高响应速度。当任务到达时,可以复用已有线程,无需等到线程创建就能立即执行
  • 提高线程的可管理性。线程是稀缺资源,如果无限制创建,不仅消耗系统资源,延长线程等待时间,还可能降低系统稳定性。

线程的几种状态

  1. 新建状态(New):新建一个线程对象
  2. 就绪状态(Runnable):调用线程的 start() 方法,线程变成可运行状态,其他资源都已经获取,只等cpu了
  3. 运行状态(Running):就绪状态的线程获取CPU,执行
  4. 阻塞状态(Blocked):由于某种原因放弃cpu使用权。可能是调用了线程的 wait() 方法、或者等待获取同步锁、或者是执行了sleep()或者其他线程执行了 join()
  5. 死亡状态(Dead):线程执行完了或者因为一场退出run()

线程生命周期

以上代码参考自:java线程池

13、死锁条件

点击看答案

产生死锁的四个条件(满足才会死锁):

  1. 互斥使用
  2. 不可剥夺
  3. 请求保持
  4. 循环等待

破坏任意一个条件即可解锁

14、引申-写个简单的死锁程序

点击看答案
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public static void main(String[] args) {
Object lock1 = new Object();
Object lock2 = new Object();

Thread thread1 = new Thread("thread1"){
@Override
public void run() {
super.run();
synchronized (lock1){
System.out.println(Thread.currentThread().getName() + "进入第1层锁");
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

synchronized (lock1){
System.out.println(Thread.currentThread().getName() + "进入第2层锁");
}
}
}
};


Thread thread2 = new Thread("thread1"){
@Override
public void run() {
super.run();
synchronized (lock2){
System.out.println(Thread.currentThread().getName() + "进入第1层锁");
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

synchronized (lock2){
System.out.println(Thread.currentThread().getName() + "进入第2层锁");
}
}
}
};

thread1.start();
thread2.start();
}

15、synchronized 关键字

点击看答案

当执行如下代码:

1
2
3
4
5
public void method() {
synchronized (this) {
System.out.println("Method 1 start");
}
}

反编译之后能看到 monitorenter 和 monitorexit 字样,关于这两条指令,jvm中的解释如下:

每个对象都有一个监视器锁(monitor),当monitor 被占用时就处于锁定状态,线程执行 monitorenter 指令时,就尝试获取 monitor的所有权,过程如下:

1、如果 monitor 的进入数为 0 ,则该线程进入 monitor,然后将进入数设置为 1,即该现成为 monitor 的所有者。

2、如果有线程已经占有 monitor ,之后又重新进入,则 monitor 的进入数加 1。

3、如果其他线程已经占用了 monitor ,则当前线程进入阻塞状态,直到 monitor 的进入数为 0(之后被唤醒),再重新获取 monitor 的所有权。

反之,monitorexit 则是线程退出的一个过程。

所以我们知道,修饰代码块时,synchronized 底层是通过 monitor 对象来完成的,其实,wait/notify方法也是依赖于 monitor 对象的,这也是为什么只有再同步块中才能调用 wait/notify 等方法(因为wait 等待的是啥?其实等待的就是对象的 monitor,由于所有类都是object ,里面内置有一个 monitor,因此自然所有类都应该有 wait/notify 方法)。

在修饰成员方法时,如下代码所示:

1
2
3
public synchronized void method() {
System.out.println("Hello World!");
}

反编译过后就会有 ACC_SYNCHRONIZED 标识符(没有用 monitorenter 和 monitorexit,其实理论上也是可以的),jvm 根据这个标识符来实现方法同步:当调用方法时,首先检查方法的 ACC_SYNCHRONIZED 标志是否被设置,如果设置了,则线程首先获取 monitor,获取成功后才能执行方法,方法执行完成后再释放 monitor,方法执行期间,其他对象无法再获得同一个 monitor。所以与修饰 代码块 的时候本质上是没有区别的。

最后,如果修饰的是静态方法,则锁定的是 class 对象。

以上内容参考自:Synchronized及其实现原理

16、synchronized 锁持有的object 是Thread 对象

点击看答案

看过上面synchronized原理,我们来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void main(String[] args) {
Thread threadTest = new Thread() {
public void run() {
System.out.println("执行线程中方法");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};

threadTest.start();

synchronized (threadTest) {//这里不明白可以想象单例的 synchronized 用法
try {
threadTest.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}

System.out.println("执行到了这里");
}

我们知道,wait 必须在同步块中执行,上述代码也是这样的;同时我们也知道,wait() 方法后,必须收到 notify/notifyAll 之后才能结束等待状态。但是上述代码首先输出 “执行线程中方法” ,之后输出 “执行到了这里” ,明明没有notify ,怎么就能结束 wait 状态呢?其实这是因为synchronized 获得对象锁是 Thread 对象的锁时,当该线程执行完成后,会调用线程自身的 notifyAll() 方法,通知所有等待在该线程对象上的线程。所以,用这种方式能够实现 join 的功能,使得线程依次执行。

以上内容参考自csdn上的博客

17、引申-理解Java 中的 join

点击看答案

如果要用join 方法实现上述功能,可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void main(String[] args) {
Thread threadTest = new Thread() {
public void run() {
System.out.println("执行线程中方法");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};

threadTest.start();

try {
threadTest.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行到了这里");
}

join 方法从字面上理解就是新线程加入进来,等新线程执行完后,老的线程才继续执行。我们日常使用 join 方法是通过 join(0) 来实现的,我们看它的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
   public final synchronized void join(long millis)
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;

if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (millis == 0) {             //如果时执行的join(0)
while (isAlive()) { //如果线程是运行状态,就会执行下面的等待
wait(0);
}
} else {                       //如果是执行的join(time)
while (isAlive()) { //如果线程时运行状态
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);                 //等待delay时间后自动返回继续执行
now = System.currentTimeMillis() - base;
}
}
}

其中 while (isAlive()) { wait(0) } ,我们日常调用的 wait() 方法就是调用的 wait(0) 实现,因此这里本质还是 执行了 wait 方法,就是让其一直等待。所以我们上述代码: threadTest.join() 本质是利用 threadTest 对象作为对象锁,当线程终止时,会调用线程自身的 notifyAll ,因此这个 wait 就解除了。

再说下 while (isAlive()),这是在判断线程是否线程是否已经执行了start() 方法,因此如果还没有start() 则不会执行wait() 方法。可以用如下代码验证:

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
public static void main(String[] args) {
final Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("t1");
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2");
}
});

t2.start();//先启动t2

try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}

t1.start();//延迟2秒后启动t1
}

这段代码看起来执行了 t1.join() ,应该要等t1执行完了才会执行t2,但是呢,这里最终输出: t2 t1 ,即 t2 还是会先执行。这是因为在执行 t1.join() 的时候,t1并没有执行 start() ,isAlive() 为false,因此不生效。

以上内容参考自csdn上的博客

18、wait/sleep/yield/join 方法

点击看答案

wait() 方法的作用是将当前线程挂起,让其进入阻塞状态,直到 notify/notifyAll 方法来唤醒线程,很容易理解,wait 是释放了锁资源的。

wait(long timeout)方法与 wait 相似,区别在于如果再指定时间内没有被notify/notifyAll ,则自动唤醒。

sleep方法只是暂时让出cpu执行权,并不释放锁,而 wait 是释放锁的。

yield 方法只是暂停当前线程,以便其他线程有机会运行,不过不能指定暂停时间,也不能保证当前线程立即停止。yield 方法只是将thread 的状态由 Running 转变为 Runable 状态。不过调度器可能会忽略这个方法,并且Java官方建议只用这个方法用于调试和测试。

join 方法是父线程等待子线程执行完之后再执行。

以上内容参考自cnblogs的博客

19、实现线程安全的方法(三种)

点击看答案
  • 互斥同步

指多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个(再使用信号量的时候可以是一些)线程使用,Java中典型的互斥同步手段是 synchronized 关键字,以及 ReentrantLock 类。

  • 非阻塞同步

互斥同步主要的问题就是线程阻塞和唤醒带来的性能问题,因此也称为阻塞同步。非阻塞同步就是先进行操作,如果没有其它线程争用共享数据,操作就成功了,否则产生冲突再补偿(比如自旋不断重试,直到成功为止),这种乐观方式无需把线程挂起和唤醒,CAS 的出现也为非阻塞同步提供了条件。

  • 无同步方案

如果方法本来就没有涉及共享数据,每次输入相同的数据就能得到相同的输出,这种情况当然无需进行同步。

20、死锁

点击看答案

产生死锁的四个条件(满足才会死锁):

  1. 互斥使用
  2. 不可剥夺
  3. 请求保持
  4. 循环等待

破坏任意一个条件即可解锁

21、引申-写个简单的死锁程序

点击看答案
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public static void main(String[] args) {
Object lock1 = new Object();
Object lock2 = new Object();

Thread thread1 = new Thread("thread1"){
@Override
public void run() {
super.run();
synchronized (lock1){
System.out.println(Thread.currentThread().getName() + "进入第1层锁");
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

synchronized (lock1){
System.out.println(Thread.currentThread().getName() + "进入第2层锁");
}
}
}
};


Thread thread2 = new Thread("thread1"){
@Override
public void run() {
super.run();
synchronized (lock2){
System.out.println(Thread.currentThread().getName() + "进入第1层锁");
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

synchronized (lock2){
System.out.println(Thread.currentThread().getName() + "进入第2层锁");
}
}
}
};

thread1.start();
thread2.start();
}

22、synchronized 关键字

点击看答案

当执行如下代码:

1
2
3
4
5
public void method() {
synchronized (this) {
System.out.println("Method 1 start");
}
}

反编译之后能看到 monitorenter 和 monitorexit 字样,关于这两条指令,jvm中的解释如下:

每个对象都有一个监视器锁(monitor),当monitor 被占用时就处于锁定状态,线程执行 monitorenter 指令时,就尝试获取 monitor的所有权,过程如下:

1、如果 monitor 的进入数为 0 ,则该线程进入 monitor,然后将进入数设置为 1,即该现成为 monitor 的所有者。

2、如果有线程已经占有 monitor ,之后又重新进入,则 monitor 的进入数加 1。

3、如果其他线程已经占用了 monitor ,则当前线程进入阻塞状态,直到 monitor 的进入数为 0(之后被唤醒),再重新获取 monitor 的所有权。

反之,monitorexit 则是线程退出的一个过程。

所以我们知道,修饰代码块时,synchronized 底层是通过 monitor 对象来完成的,其实,wait/notify方法也是依赖于 monitor 对象的,这也是为什么只有再同步块中才能调用 wait/notify 等方法(因为wait 等待的是啥?其实等待的就是对象的 monitor,由于所有类都是object ,里面内置有一个 monitor,因此自然所有类都应该有 wait/notify 方法)。

在修饰成员方法时,如下代码所示:

1
2
3
public synchronized void method() {
System.out.println("Hello World!");
}

反编译过后就会有 ACC_SYNCHRONIZED 标识符(没有用 monitorenter 和 monitorexit,其实理论上也是可以的),jvm 根据这个标识符来实现方法同步:当调用方法时,首先检查方法的 ACC_SYNCHRONIZED 标志是否被设置,如果设置了,则线程首先获取 monitor,获取成功后才能执行方法,方法执行完成后再释放 monitor,方法执行期间,其他对象无法再获得同一个 monitor。所以与修饰 代码块 的时候本质上是没有区别的。

最后,如果修饰的是静态方法,则锁定的是 class 对象。

以上内容参考自:Synchronized及其实现原理

23、synchronized 锁持有的object 是Thread 对象

点击看答案

看过上面synchronized原理,我们来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void main(String[] args) {
Thread threadTest = new Thread() {
public void run() {
System.out.println("执行线程中方法");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};

threadTest.start();

synchronized (threadTest) {//这里不明白可以想象单例的 synchronized 用法
try {
threadTest.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}

System.out.println("执行到了这里");
}

我们知道,wait 必须在同步块中执行,上述代码也是这样的;同时我们也知道,wait() 方法后,必须收到 notify/notifyAll 之后才能结束等待状态。但是上述代码首先输出 “执行线程中方法” ,之后输出 “执行到了这里” ,明明没有notify ,怎么就能结束 wait 状态呢?其实这是因为synchronized 获得对象锁是 Thread 对象的锁时,当该线程执行完成后,会调用线程自身的 notifyAll() 方法,通知所有等待在该线程对象上的线程。所以,用这种方式能够实现 join 的功能,使得线程依次执行。

以上内容参考自csdn上的博客

24、引申-理解Java 中的 join

点击看答案

如果要用join 方法实现上述功能,可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void main(String[] args) {
Thread threadTest = new Thread() {
public void run() {
System.out.println("执行线程中方法");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};

threadTest.start();

try {
threadTest.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行到了这里");
}

join 方法从字面上理解就是新线程加入进来,等新线程执行完后,老的线程才继续执行。我们日常使用 join 方法是通过 join(0) 来实现的,我们看它的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
   public final synchronized void join(long millis)
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;

if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (millis == 0) {             //如果时执行的join(0)
while (isAlive()) { //如果线程是运行状态,就会执行下面的等待
wait(0);
}
} else {                       //如果是执行的join(time)
while (isAlive()) { //如果线程时运行状态
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);                 //等待delay时间后自动返回继续执行
now = System.currentTimeMillis() - base;
}
}
}

其中 while (isAlive()) { wait(0) } ,我们日常调用的 wait() 方法就是调用的 wait(0) 实现,因此这里本质还是 执行了 wait 方法,就是让其一直等待。所以我们上述代码: threadTest.join() 本质是利用 threadTest 对象作为对象锁,当线程终止时,会调用线程自身的 notifyAll ,因此这个 wait 就解除了。

再说下 while (isAlive()),这是在判断线程是否线程是否已经执行了start() 方法,因此如果还没有start() 则不会执行wait() 方法。可以用如下代码验证:

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
public static void main(String[] args) {
final Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("t1");
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2");
}
});

t2.start();//先启动t2

try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}

t1.start();//延迟2秒后启动t1
}

这段代码看起来执行了 t1.join() ,应该要等t1执行完了才会执行t2,但是呢,这里最终输出: t2 t1 ,即 t2 还是会先执行。这是因为在执行 t1.join() 的时候,t1并没有执行 start() ,isAlive() 为false,因此不生效。

以上内容参考自csdn上的博客

25、wait/sleep/yield/join 方法

点击看答案

wait() 方法的作用是将当前线程挂起,让其进入阻塞状态,直到 notify/notifyAll 方法来唤醒线程,很容易理解,wait 是释放了锁资源的。

wait(long timeout)方法与 wait 相似,区别在于如果再指定时间内没有被notify/notifyAll ,则自动唤醒。

sleep方法只是暂时让出cpu执行权,并不释放锁,而 wait 是释放锁的。

yield 方法只是暂停当前线程,以便其他线程有机会运行,不过不能指定暂停时间,也不能保证当前线程立即停止。yield 方法只是将thread 的状态由 Running 转变为 Runable 状态。不过调度器可能会忽略这个方法,并且Java官方建议只用这个方法用于调试和测试。

join 方法是父线程等待子线程执行完之后再执行。

以上内容参考自cnblogs的博客

26、Java 中锁的种类

点击看答案

大概可以分类为:

  • 乐观锁、悲观锁
  • 独享锁、共享锁
  • 公平锁、非公平锁
  • 互斥锁、读写锁
  • 可重入锁
  • 分段锁

其中乐观锁认为线程基本上是没有竞争的,一般采用CAS机制实现,悲观锁认为一个线程获取数据时,一定会有其他数据对数据进行更改,它的实现就是加锁,比如 synchronize 关键字

至于独享锁:一次只能被一个线程拥有,ReentrantLock 这个可重入锁也是独享锁。而共享锁可以被多个线程持有,比如:ReentrantReadWrite 中的读锁ReadLock 是共享锁,写锁WriteLock 是独享锁。

互斥锁的具体实现就是 synchronized、ReentrantLock 等实现;读写锁具体实现就是 ReadWriteLock。

可重入锁:对于同一个线程,在外层方法获取锁的时候,在进入内层方法也会自动获取锁。

公平锁:多线程竞争时排队,按照申请顺序获取锁;非公平锁:竞争时,先尝试插队,失败再排队。

分段锁:并不是一种锁,只是细化锁的粒度。

以上内容参考自知乎上的内容

27、Java 中锁优化的方式

点击看答案
  • 自旋锁与自适应自旋(主要解决互斥同步挂起和恢复需要转入内核态的问题。引入的自适应自旋,意味着自旋时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定的,因为如果在同一个锁对象上,自旋等待刚获得过锁,并且持有锁的线程正在运行,那么虚拟机认为当前自旋也很有可能再次成功,进而允许自旋等待更长时间)
  • 锁消除(虚拟机对于检测到不可能存在共享的数据进行锁消除)
  • 锁消除(一般来说,加锁同步块区域尽量小,只锁真正共享的区域,以便在锁竞争的时候,能尽快释放。但是如果一系列的连续操作对同一个对象频繁反复加锁解锁,甚至是出现在循环体中,那就会导致不必要的性能损耗,典型的就是在 循环中执行StringBuilder 的append操作)
  • 轻量级锁(如果同步对象没有被锁定,虚拟机首先将将在当前线程的栈帧中建立一个名为锁记录-Lock Record 的空间,用于存储锁对象目前的 Mark Word的拷贝,然后虚拟机使用CAS操作尝试把对象的Mark Word 更新为指向 Lock Record 的指针,如果更新成功了,则代表线程拥有了这个对象的锁,对象Mark Word的锁标志也会标记为 “00”,即轻量级锁状态。如果这个cas操作失败,则意味着至少存在一条线程竞争,此时检查Mark Word 是否指向当前线程的栈帧,如果是,则说明已经拥,有了,直接进入同步块即可。否则,如果有两条以上线程争用一个锁,则必须膨胀微重量级锁。轻量级锁的依据是,“对于绝大部分锁,在整个同步周期内是不存在竞争的”,如果没有竞争,则轻量级锁通过CAS操作避免了使用互斥量的开销;当然,如果存在竞争,除了互斥量本身之外,还发生了CAS开销,反而更慢了)
  • 偏向锁(它的目的是在无竞争的情况下消除CAS操作,进一步提升性能。当锁对象第一次被线程获取的时候,就将对象头中的偏向模式置为 “1”,即偏向模式,同时使用CAS操作把获取这个锁的线程的ID记录在Mark word 中,如果操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块都不需要进行同步操作,不过,一旦出现另外一个线程尝试去获取这个锁的情况,偏向模式马上结束。根据锁对象目前是否处于锁定状态来决定是否撤销偏向、恢复到未锁定或者轻量级锁定状态)

以上内容参考自《深入理解Java虚拟机-第三版》

1、Java 多态

点击看答案

多态存在的三个必要条件:一、要有继承关系 二、要重写方法 三、父类指向子类对象。

多态就是指允许不同类的对象(如:父类的多个子类),对同一消息做出不同响应(同一个函数调用在不同子类中的行为不同)。

多态的实现:动态绑定(dynamic binding),即在执行期间判断所引用的对象的实际类型,根据实际类型再调用相应方法。

Java 中多态的表现:接口的实现、继承父类进行方法重写 以及 同一个类中进行方法重载。

以上内容参考自: Java多态

Java集合

2、ArrayList 与 LinkedList的区别

点击看答案

ArrayList的特点:

  • 以数组实现,初始空间是10,节约空间
  • 有容量限制,当超出限制时,新增50%容量,即容量变为原来的1.5倍,如果还不够,则直接扩充为需求值,之后将原来数据拷贝到新的空间中,比较耗时
  • 按照数组下标访问元素——get(i)/set(i,e) 性能很高
  • 按照下标插入、删除元素,需要移动受影响的元素,性能就会变差(remove操作可以理解为删除index为0的节点,并将后面的元素移到0处)

LinkedList的特点:

  • 以双向链表实现,链表无容量限制,但是双向链表本身使用了更多空间
  • 按下标访问元素——get(i)/set(i,e),要遍历链表将指针移动到位(如果i>链表的一半,会从末尾开始移动)
  • 插入、删除元素时,只需要修改前后节点的指针即可。但如果是指定位置插入和删除,则还是需要遍历部分链表的指针才能移动到下标所指的位置。如果只是在链表两头的操作就能省掉指针的移动。

参考链接:ArrayListLinkedList)

3、HashMap

点击看答案

根据官方描述,HashMap 基于 Map 接口实现,允许null 键/值,非同步、不保证有序(比如插入顺序)、顺序可能会随时间变化。

两个重要的参数

容量(Capacity)就是bucket大小,负载因子(Load factor)就是bucket填满程度的最大比例。若对迭代性能要求高,则capacity不宜设置过大,同时load factor也不宜设置过小;当buckets的数目大于 capacity * load factor 时,就需要调整buckets 的大小为当前的2倍。

hashMap的put函数实现

  1. 对key的hashCode 做hash,然后计算index;
  2. 如果没有碰撞直接放到bucket里;
  3. 如果碰撞了,以链表的形式存在buckets 后;
  4. 如果碰撞导致链表过长,达到某个阈值后,则把链表转换成红黑树
  5. 如果节点已经存在,就替换 old value (保证key的唯一性);
  6. 如果bucket满了,就要resize;
  7. 注意,插入元素采用头插法,因为HashMap的发明者认为,后插入的Entry被查找的可能性更大

get函数的实现

  1. bucket里第一个节点,则直接命中;
  2. 如果有冲突,则通过 key.equals(k) 方式去查找对应的 entry。

若为树,则树中通过key.equals查找,时间复杂度为 O(logn);
若为链表,则链表中通过key.equals查找,时间复杂度为 O(n)。

hashmap细节

  • hashmap的初始长度是16,并且手动初始化或者每次自动扩展时,长度必须是2的幂

这里主要强调是2的幂,至于为什么是16,主要是为了让key到index的映射更加均匀。前面提到,index = Hash(key) ,如何实现一个尽量分布均匀地hash函数。有人说可以通过求余的方式: index = hashCode(key) % length ,不过求余的方式虽然简单,但是效率不高,Hashmap中采用了位运算方式,其公式为:

index = hashCode(key) & (length - 1)

以具体例子来说,假如某个key 的 hashCode(二进制) = 101110001110101110 1001,hashmap的默认长度length = 16 ,则 length - 1 为15,二进制数据为 1111,把两个二进制数据做位与操作得到 1001,即十进制的 9 ,所以index = 9。所以这里,index的结果完全取决于hashCode的最后4位(当然,java8中,会将这个hashCode的高16位不变,低16位和高16位做异或操作作为低16位的值,之后才与 length -1 做位与,这样避免只有低4位是有效位,从而进一步降低碰撞,因为参与的位数多了)

所谓为什么要是2的幂,也即如果不是2的幂会怎样?比如hashMap的长度是 10,还是以上面的例子:hashCode(二进制) = 101110001110101110 1001,length - 1 = 9 ,即 1001,则index 也还会是1001(因为hashCode的后4位也是1001),单独看这里没什么问题,但是从此我们可以推断:如果hashCode的后 4 位是 1001、1101、1111,1101等等,它们的结果都会是 1001,因为相当于只有第1位和第4位在起作用,这不符合index均匀分布的要求。那如果是2的幂呢?则length - 1后,所有的位数都是1,则每位都会起作用

Hashmap的容量是有限的,当容量达到一定的饱和度的时候,Key映射位置发生碰撞的概率会上升,这好理解,因为如果每个坑都差不多有entry在了,无论你index是多少,都会碰撞,所以元素越多,越容易发生碰撞。java中的条件是 : hashmap.size >= capacity * load factor 的时候,就需要resize,需要经历两个步骤,1、扩容,创建一个新的Entry数组,长度是以前的2倍,2、ReHash,遍历原来的 Entry数组,把所有的Entry 重新hash到新的数组,因为数组长度变化了,hash的规则也会改变,所以需要rehash。这里不需要重新计算hash,只需要判断原来的hash值新增的那位是0 还是1,如果是0的话,索引还是没变化,如果是1,则索引变成 “原索引 + oldCapacity”。

举个例子拉说,如果以前的capacity 是 8,则resize后变成16,以前的length - 1 为 111 现在则变成了 1111,多了一个有效位,所以只要判断 hashCode 的对应新增的那位的值是0还是1了,0的话,整个index还是不变,1的话,就在index的基础上加上老的容量 8 即可。

  • 前面提到,key和value都可能为null,如果key为null,则直接从哈希表的第一个位置table[0]对应的链表上查找。记住,key为null的键值对永远都放在以table[0]为头结点的链表中。

  • Hashmap的线程不安全如何体现?

  1. 如果多个线程同时使用put添加元素,如果发生碰撞,最终只有一个线程值被保存,因为另一个的会被覆盖。
  2. 由于resize操作存在,hashmap在多线程的情况下,可能会出现死循环,具体参考:小灰的解释

参考链接: 知乎链接hashmap介绍链接

4、HaShMap 链表元素到达8的时候转红黑树的若干问题

点击看答案

首先,得满足两个条件才会转红黑树:一个是链表长度到8,一个是数组长度到64

为什么到8才转成红黑树?首先根据统计节点数>=8概率是很小的(千分之一),并且到8的时候,会引起性能下降,且因为转红黑树消耗性能,所以到 8 才转。

会根据红黑树状态以及红黑树节点总数到6这个阈值来将红黑树退回链表,这主要是是因为 8 和 6 这两个数字相差2,不至于插入删除一个元素导致来回转换

为什么不一开始就采用红黑树?因为红黑树是有额外的空间开销的,并且红黑树涉及左旋右旋等操作(我自己臆测的,也没看到有好的说法)

以上问题参考自hashmap转红黑树的两个条件 HashMap的问题cnblogs的博客

5、HashTable

点击看答案

HashTable 类似于HashMap ,它同样基于hash表实现,每个元素也是key-value 对,也是通过单链表解决冲突,容量不足时,也会resize,二者区别是:

  1. HashTable 的key和Value 都不能为null,而HashMap允许。
  2. HashTable 默认大小是 11,扩容方式是 old*2 + 1,而HashMap 默认大小是16,要求数组大小是2的幂,扩容时,直接扩为2倍。
  3. 获取index的方式不一样,Hashtable 采用除余的方式,而HashMap采用 位与的方式,效率更高。
  4. HashTable 保证方法调用的线程安全,因为在每个方法前都有synchronize 关键字。而HashMap 没有,因此在线程安全条件下效率更高。

上文的参考链接

6、ConcurrentHashMap

点击看答案

想要避免HashMap 的线程安全问题有很多办法,比如采用 HashTable 或者 Collections.synchronizedMap ,但是这两者有共同的问题:性能,因为无论是读还是写操作,它们都会给整个集合加锁,导致同一时间的其他操作阻塞。如下图所示:

阻塞其他操作

此时,ConcurrentHashMap应运而生,理解 ConcurrentHashMap 关键要理解一个概念: Segment 。Segment 本身就相当于一个 HashMap 对象,Segment 包含一个HashEntry 数组,数组中每个 HashEntry 既是一个键值对,也是一个链表的头结点,如下图所示:

单一segment结构

这样的Segment 在ConcurrentHashMap 中有2 的N 次方个,共同保存在一个名为 segments 的数组中。因此,整个 ConcurrentHashMap 的结构如下:

ConcurrentHashMap结构

这个二级结构,和数据库的水平拆分有些相似。采取这样的结构就是锁分段技术,每个segment 就好比一个自治区,读写操作互不影响。所以,ConcurrentHashMap 操作会有以下几种可能性:

  • 不同Segment 可以并发写入。
  • 同一 Segment 可以同时读和写。
  • 同一个 Segment 并发写入时,只有一个线程可以执行,其他的线程阻塞。因为 Segment 的写入会加锁。

通过以上分析我们知道,ConcurrentHashMap 中每个 Segment 各自持有一把锁。在保证线程安全的情况下,降低了锁的粒度,让并发操作效率更高。

get 方法

  1. 为输入的key做 Hash 运算,得到hash值。
  2. 通过hash值,定位到对应的 Segment 对象。
  3. 再次通过 hash 值,定位到 Segment 中数组的具体位置。

put 方法

  1. 为输入的key 做Hash 运算,得到hash 值。
  2. 通过hash值,定位到 Segment 对象。
  3. 获取可重入锁。
  4. 再次通过 hash 值,定位到 Segment 当中的具体位置。
  5. 插入或者覆盖 HashEntry 对象。
  6. 释放锁。

size 方法

获取 ConcurrentHashMap 总元素数量,自然要把各个 Segment 的元素汇总起来,但是如果在统计过程中,已经统计过的 Segment 瞬间插入新的元素,这时候怎么办呢?其实,这个size调用过程的大体逻辑如下:

  1. 遍历所有 Segment。
  2. 把 Segment 的元素数量累加。
  3. 把 Segment 的修改次数累加起来。
  4. 判断所有Segment 的总修改次数是否大于上一次的修改次数,如果大于,说明统计过程中有修改,重新统计,同时尝试次数 +1;否则,说明没有修改,统计结束。
  5. 如果尝试次数超过阈值,则对每一个Segment 加锁,再重新统计。
  6. 此时,统计的结果肯定正确,统计结束,释放锁。

为了尽量不锁住所有的Segment ,首先乐观假设Size过程中不会有修改,当尝试一定次数后,才无奈转换为悲观锁。

以上文章参考小灰的分析

7、HashMap 、 HashTable 和 ConcurrentHashMap 的区别?

点击看答案

HashTable 是 HashMap 的线程安全实现,但是 HashTable在竞争激烈时效率低下,因为访问所有 HashTable 的线程都竞争同一把锁。ConcurrentHashMap 采用锁分段技术,将数据一段段存储,每段一把锁,当两个线程访问不同段数据时不受干扰,当然,contentValue和size等方法需要了解整体数据的情况下,还需要锁住整个表。

8、LinkedHashMap

点击看答案

LinkedHashMap 是Hash表和链表的实现,并且保存了记录的插入顺序。因为LinkedHashMap里面的Entry比HashMap多了两个字段:after和before,而以前的HashMap中的next 字段没有变化,从而额外构成一个双向链表,当然就可以在keySet()时按插入顺序输出,LinkedHashMap结构如下图所示:

LinkedHashMap原理

与HashMap的区别:1、保存了记录的插入顺序,遍历的时候,首先打印最先插入的记录。2、遍历的时候比HashMap慢,因为LinkedHashMap 遍历链表,而HashMap可以说是根据capacity 遍历链表。不过如果HashMap容量很大并且实际数据比较少的情况下,遍历起来可能比LinkedHashMap慢。3、HashMap的遍历速度和容量有关,而LinkedHashMap 遍历速度只和实际数据有关。

以上内容参考这个链接,从知乎这个问题得到了灵感,忽然想明白了这个结构。

9、WeakHashMap

点击看答案

WeakHashMap,从名字可以看出它是某种 Map。它的特殊之处在于 WeakHashMap 里的entry可能会被GC自动删除,即使程序员没有调用remove()或者clear()方法。

更直观的说,当使用 WeakHashMap 时,即使没有显示的添加或删除任何元素,也可能发生如下情况:

  • 调用两次size()方法返回不同的值;
  • 两次调用isEmpty()方法,第一次返回false,第二次返回true;
  • 两次调用containsKey()方法,第一次返回true,第二次返回false,尽管两次使用的是同一个key;
  • 两次调用get()方法,第一次返回一个value,第二次返回null,尽管两次使用的是同一个对象。

遇到这么奇葩的现象,你是不是觉得使用者一定会疯掉?其实不然,WeekHashMap 的这个特点特别适用于需要缓存的场景。在缓存场景下,由于内存是有限的,不能缓存所有对象;对象缓存命中可以提高系统效率,但缓存MISS也不会造成错误,因为可以通过计算重新得到。

以上内容参考 知乎的大神

10、TreeMap

点击看答案

HashMap不保证数据有序,LinkedHashMap保证数据可以保持插入顺序,而如果我们希望Map可以保持key的大小顺序的时候,我们就需要利用TreeMap了。从官方的描述来看:

A Red-Black tree based {@link NavigableMap} implementation.The map is sorted according to the {@linkplain Comparable natural ordering} of its keys, or by a {@link Comparator} provided at map creation time, depending on which constructor is used.

TreeMap 是一个红黑树结构,每个key-value都作为一个红黑树的节点。它根据 key 排序(Comparable自然排序),但假如 key 没有实现 Comparable 接口,还可以通过构造函数中传入的 Comparator 来自定义比较。并且它还间接实现了 SortedMap 接口,因此它是有序的集合。

使用红黑树的好处是能够使得树具有不错的平衡性,这样操作的速度就可以达到log(n)的水平了。

关于根据 key 排序这个表述,可能直接看代码更容易懂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public V put(K key, V value) {

...

Comparator<? super K> cpr = comparator;
if (cpr != null) {

...

cmp = cpr.compare(key, t.key);

...
} else {
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;

...

}
}

省略了很多代码,如果有自定义的 comparator,则使用自定义的 comparator 比较;否则,将 Key 强转为 Comparable 类型,再做比较。当然,这个Key肯定不能为null,此外,官方也说明了,如果Key不是 Comparable 类型的,就会抛出 ClassCastException 异常。

以上文章参考java集合-TreeMaposchina链接 还有这个大牛

11、Java泛型

点击看答案

Java 泛型主要关注几点:

类型通配符

顾名思义就是匹配任意类型。如如下写法:List<?> list ;

带限通配符

上限通配符:使用extends 关键字指定这个类型必须继承某个类或者实现某个接口,也可以是该类(接口)本身。如: List<? extends Shape> ,表示集合中所有元素都是Shape 类型或者它的子类。

下限通配符:使用super 关键字指定这个类型必须是某个类的父类,或者某个接口的父接口,也可以是这个类本身。如:List<? super Circle> ,表示集合中所有元素都是Circle 类型或者是其父类。

类型擦除

Class c1=new ArrayList().getClass();
Class c2=new ArrayList().getClass();
System.out.println(c1==c2);

输出 true ,也就是说编译后的class文件中不会包含任何泛型信息,泛型信息不会进入到运行时阶段。

由于系统中并不会真正生成泛型类,所以instanceof运算符后不能使用泛型类

以上内容参考自Java泛型

12、Java抽象类和接口的区别

点击看答案

abstract class和interface是Java语言中对于抽象类定义进行支持的两种机制,正是由于这两种机制的存在,才赋予了Java强大的面向对象能力。

abstract class和interface之间在对于抽象类定义的支持方面具有很大的相似性,甚至可以相互替换,因此很多开发者在进行抽象类定义时对于 abstract class和interface的选择显得比较随意。

但是,对于它们的选择甚至反映出对于问题领域本质的理解、对于设计意图的理解是否正确、合理。

总结:

  • 设计理念上,接口反映的是”like-a”关系,抽象类反映的是”is-a”关系,即接口表示这个对象能做什么,抽象类表示的是这个对象是什么(想象一下,人可以吃东西,狗也能吃东西,接口反映的是吃东西这个动作,而抽象类能反映的,可能就是人这个物种)。
  • 抽象类与接口都不能直接实例化。
  • 抽象类被子类继承,接口被子类实现。
  • 接口中定义的变量只能是公共的静态常量(即 public static final),抽象类中是普通变量。
  • 抽象类中可以没有抽象方法,接口中可以没有方法,但是有方法一定要有抽象方法。
  • 接口可以被类多实现(类可以实现多个接口),抽象类只能被单继承。
  • 接口中没有this 指针,没有构造函数,不能拥有实例变量或实例方法。

关于接口,再多啰嗦几句:

  • 接口用于描述系统对外提供的服务,因此接口中的成员变量和方法都必须公开(public),确保所有使用者能访问。
  • 接口仅描述系统能做什么,但不指名如何做,因此所有方法都是抽象(abstract)方法。
  • 接口不涉及任何具体实例(this关键字)的相关细节,因此接口没有构造方法,没有实例变量,只有静态(static)变量。
  • 接口中的变量是所有实现类公有的,既然公有,肯定是不变的东西,所以变量是不可变(final)的。

通俗讲,你认为是要变化的东西,就放在你自己的实现中,不能放在接口中。接口对修改关闭,对扩展开放,是开闭原则的体现。

以上内容参考自:Java抽象类和接口的区别程序媛想事

13、Java transient关键字

点击看答案

Java序列化时,transient关键字用于属性前时,该属性就不会被序列化。它的使用可以总结为下面几点:

  • 变量被 transient 修饰时,变量将不会是对象持久化的一部分。
  • transient 只能修饰变量而不能修饰方法和类,并且也不能修饰本地变量。
  • 静态变量不管是否被 transient 修饰,均不能被序列化。

附:父类实现了Serializable,子类没有,
父类有int a = 1、int b = 2、int c = 3
子类有int d = 4、int e = 5
序列化子类的时候,d和e会不会被序列化?(答案:会)

反过来父类未实现Serializable,子类实现了,序列化子类实例的时候,父类的属性是直接被跳过不保存,还是能保存但不能还原?(答案:值不保存)

以上内容参考自:Java transient关键字

14、Java finally与return执行顺序

点击看答案

首先探讨下,try-catch-finally 块中的语句是否一定被执行?答案是否定的,原因有2个:

  • 如果try 语句没有被执行(比如在try 之前就return 了),finally就不会执行。
  • 如果try 块中有 System.exit(0)这样的终止Java 虚拟机的语句的话,finally就不会执行。这可以理解,连JVM 都停止了,啥都没有了。

关于finally 与return 的执行顺序,过程比较复杂,可以分为如下情况:

  • 正常情况下,finally 语句在return 语句执行之后,return 返回之前执行的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class FinallyTest1 {

public static void main(String[] args) {
System.out.println(test11());
}

public static String test11() {
try {
System.out.println("try block");
return test12();
} finally {
System.out.println("finally block");
}
}

public static String test12() {
System.out.println("return statement");
return "after return";
}

}

会输出:

try block
return statement
finally block
after return

可以看出,try 中的return语句先执行了,但是值没有立即返回,等finally执行结束后再返回值。

  • 如果finnaly 块中有return 语句会覆盖 try 中的 return 返回。
  • 如果finally语句中没有return语句覆盖返回值,那么原来的返回值可能因为finally里的修改而改变也可能不变(int 类型和 Map 类型)。
  • try 块里的return 语句在异常情况下不会执行,怎么返回看情况。

以上内容参考自:Java finally与return执行顺序

15、两个对象的 hashcode 相同,是否对象相同?equal() 相同呢?

点击看答案
  1. hashCode是所有java对象的固有方法,默认返回的是该对象在jvm的堆上的内存地址,不同对象的内存地址肯定不同,所以这个hashCode也就肯定不同了。如果重载了的话,由于采用的算法的问题,有可能导致两个不同对象的hashCode相同。
  2. hashCode和equals两个方法是有语义关联的,它们需要满足:
    A.equals(B)==true –> A.hashCode()==B.hashCode(),但是反之不能说hashcode相等就equals
    因此重载其中一个方法时也需要将另一个也重载。
  3. 此外,请注意:hashCode的重载实现最好依赖于对象中的final属性,从而在对象初始化构造后就不再变化。一方面是jvm便于代码优化,可以缓存这个hashCode;另一方面,在使用hashMap或hashSet的场景中,如果使用的key的hashCode会变化,将会导致bug,比如放进去时key.hashCode()=1,等到要取出来时key.hashCode()=2,就取不出来数据了。
    综上所述,hashCode相同或者equals相同并不能说明对象相同。

16、延伸-Java 中 hashcode 的作用

点击看答案

官方文档的定义就是:

hashcode 方法返回对象的哈希码值,支持该方法主要是为了支持基于哈希机制的Java 集合类,如HashMap、HashSet、HashTable 等。

hashcode的常规约定是:

Java程序运行期间,同一个对象上多次调用 hashcode ,必须一致地返回相同的整数,而从某一应用程序的一次执行到同一程序的另一次执行,该整数无须保持一致。如果两个对象相等,那么两个对象中的每个对象上调用 hashcode 方法都必须生成相同的整数结果。

以上内容参考自:OUYM

17、延伸-为什么重写了equals 方法,要求必须重写hashcode 方法?

点击看答案

根据前面的内容,总结就是:为了满足常规约定-如果两个equals 满足,就一定要求返回相同的 hashcode。举个例子,如果重写了 equals 方法,对象中 num 和data 参与了equals 比较,那么 num 和data 也要参与生成hashcode,这是为了遵守上述约定。

18、生成hashcode 注意的事项

点击看答案
  • 返回的hash值是int型,防止溢出
  • 不同对象返回的hash值尽量不同(为了hashmap 等集合减少碰撞)
  • 无论何时,对同一个对象调用hashcode()都应该产生同样的值

最后一点是很重要的,也是容易出隐形bug的地方,如果将一个对象put() 到HashMap 时产生了一个 hashcode 值,而 get() 取出时却产生了另外一个hashcode,那么就无法获取该对象了。所以,如果hashcode() 方法依赖于对象中易变的数据,那就要当心了。

以上内容参考自:OUYM

19、下面代码输出的结果?

点击看答案
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
//题目1
String str1 = "hello";
String str2 = "he" + new String("llo");
System.err.println(str1 == str2);

//答案是false,因为str2中的llo是新申请的内存块,而==判断的是对象的地址而非值,所以不一样。

//题目2
public static void main(String args[]) {

Thread t = new Thread() {

public void run() {
pong();
}
};

t.run();
System.out.print("ping");

}

static void pong() {
System.out.print("pong");
}
}

//输出 pongping ,这里调用的是Thread 的run 方法,与普通方法是一样的,失去线程的特性了。因此我们要启动新县城执行的时候,需要使用Thread 的 start() 方法。

//题目3,如下代码是否能正常运行?
public class NULL {
public static void test(){
System.out.println("haha");
}
public static void main(String[] args) {
((NULL)null). test();
}
}

//能正常运行,输出 haha 。首先,null 值可以强转成任何 Java 类型,所以 (NULL)null 、(String)null 等都是合法的。其次,test 方法是static 方法,只和类名绑定,不借助对象进行访问。综上,能正常输出。反之,如果 test 非static ,就只能使用对象访问,这时候使用null对象肯定会报空指针。

//题目4,如何输出
class People {
String name;

public People() {
System.out.print(1);
}

public People(String name) {
System.out.print(2);
this.name = name;
}
}

class Child extends People {
People father;

public Child(String name) {
System.out.print(3);
this.name = name;
father = new People(name + ":F");
}

public Child() {
System.out.print(4);
}

}

//会输出132,在Java 中,子类的构造过程中必须调用其父类的构造函数,因为有继承关系存在,子类要把父类的内容继承下来。

//题目5,如何输出
public class HelloA {

public HelloA() {
System.out.println("HelloA");
}

{
System.out.println("I'm A class");
}

static { System.out.println("static A"); }
}

class HelloB extends HelloA {
public HelloB() {
System.out.println("HelloB");
}

{
System.out.println("I'm B class");
}

static {
System.out.println("static B");
}

public static void main(String[] args) {

System.out.println("-------main start-------");
new HelloB();
new HelloB();
System.out.println("-------main end-------");
}
}

//会输出如下结果:
static A
static B
-------main start-------
I'm A class
HelloA
I'm B class
HelloB
I'm A class
HelloA
I'm B class
HelloB
-------main end-------

这个题目很好,考察静态语句块、构造语句块(就只有大括号的那块) 以及 构造函数执行顺序。

对象初始化顺序:1. 类加载之后,从父类到子类执行被static 修饰的语句; 2.static 执行完毕后,再执行main 方法; 3.如果有语句new自身对象,将从父类到子类执行构造语句块、构造器。

20、延伸-类的初始化步骤

点击看答案

没有父类的情况:

  1. 类的静态属性
  2. 类的静态代码块
  3. 类的非静态属性
  4. 类的非静态代码块
  5. 构造方法

有父类的情况:

  1. 父类的静态属性
  2. 父类的静态代码块
  3. 子类的静态属性
  4. 子类的静态代码块
  5. 父类的非静态属性
  6. 父类的非静态代码块
  7. 父类构造方法
  8. 子类非静态属性
  9. 子类非静态代码块
  10. 子类构造方法

以题目深化理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Singleton {
private static Singleton singleton = new Singleton();
public static int counter1;
public static int counter2 = 0;

private Singleton(){
counter1 ++;
counter2 ++;
}

public static Singleton getSingleton(){
return singleton;
}
}

public class TestSingleton {
public static void main(String args[]){
Singleton singleton = Singleton.getSingleton();
System.out.println("counter1 = " + singleton.counter1);
System.out.println("counter2 = " + singleton.counter2);
}
}

上述代码将输出:

counter1 = 1
counter2 = 0

根据类初始化步骤,由于 Singleton 并没有被加载过,所以首先执行类加载步骤,在“准备”阶段,首先给静态变量赋初默认值:
singleton = null
counter1 = 0
counter2 = 0

加载和连接完毕,再进行初始化工作,依照代码写的顺序依次执行,首先执行 singleton = new Singleton();这样就会执行构造方法的内部逻辑,即此时 counter1 = 1; counter2 = 1;

接下来,由于counter1 只进行了定义,并没有初始化,所以counter1的值仍然为1 ;接下来,counter2 进行了定义并且赋值 0 ,则初始化阶段后,counter2 的值为0;

初始化完毕,要调用Singleton.getSingleton() ,由于singleton 的值已经初始化过,此时直接返回即可。因此输出 counter1 = 1,counter2 = 0。

反之,如果将静态变量初始化的顺序改变下:

1
2
3
public static int counter1;
public static int counter2 = 0;
private static Singleton singleton = new Singleton();
则会输出 counter1 = 1,counter2 = 1 了,按照上述推理应该能够理解。

以上内容参考自:码个蛋,同时可以参考以前写的虚拟机的相关文章

21、constructor 是否一定要与类名同名,方法名是否一定不能与类名同名?

点击看答案

constructor 是一定要与类名同名的,但普通的类方法是可以和类名同名的,它与构造方法唯一的区别就是构造方法没有返回值。

22、数据溢出与非法数据问题

点击看答案

存在使i + 1 < i的数吗?答案是肯定的,比如i是int 类型,那么当i 是最大的整数时,i+1就溢出了,就可能< i

是否存在 i>j || i<= j 不成立?答案是肯定的,比如:Double.NaN 或者 Float.NaN

以上内容参考自程序媛想事

23、Java 的参数传递

点击看答案

在讨论之前,首先看下如下代码的输出情况:

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

String str = new String("good");

char[] ch = { 'a', 'b', 'c' };

public static void main(String args[]) {
Example ex = new Example();
ex.change(ex.str, ex.ch);
System.out.print(ex.str + " and ");
System.out.print(ex.ch);
}

public void change(String paramA, char paramB[]) {
paramA = "test ok";
paramB[0] = 'g';
}
}

上述代码输出good and gbc 。在Java 中没有引用传递,只有值传递,这个值指的是实参的地址的拷贝,得到这个值(地址拷贝)后,你可以通过它修改这个地址的内容,因为此时这个内容的地址和原地址是同一个地址,但是你不能改变这个地址本身使其重新引用到其他对象。以上的意思说明仅仅只是值传递。具体过程如果使用图示的话,如下所示:

str的传递:

字符串传递

在change方法中重新为paramsA 赋值:

字符串传递

ch的传递:

数组传递

在change方法中更改paramsB 的元素值:

数组更改值

以上内容参考自:程序媛想事RavenXRZ

24、静态属性和静态方法是否可以被继承?是否可以被重写?以及原因?

查看以前写的这篇读书笔记即可

25、String、StringBuilder、StringBuffer、CharSequence 区别

点击看答案

CharSequence 是一个接口,String、StringBuilder、StringBuffer 都实现了这个接口,它们三个的本质都是通过字符数组实现的。

StringBuilder、StringBuffer 的char 数组开始的存储空间是16,如果append() 过程中超过这个容量,将会申请新的空间,并把老的数组一起复制过去。

StringBuffer 的每个处理方法都加上了 synchronized 关键字,因此可以说它是线程安全的。

26、Java 中String 为毛要设计成不可变?

点击看答案

为什么说String 是不可变的

首先我们看源码:

1
2
3
public final class String implements Serializable, Comparable<String>, CharSequence {
private final char[] value;
}

类使用final 修饰,说明不可继承;存放内容的 value 是个char[]数组,也是final修饰,创建以后就不可改变。说明一下,这个 value 是stack 上的一个引用,数据本身还是在heap堆上。final 修饰value ,只能是说stack 指向的引用地址是不可变的,但是堆里面的数据本身还是可变的!举例理解下:

1
2
3
4
5
6
final int[] value={1,2,3}
int[] another={4,5,6};
value = another;//编译器报错,final不可变

final int[] value={1,2,3};
value[2]=100;//这时候数组里已经是{1,2,100}

通过以上代码相信就能理解上面描述的意思了。也许有人还认为String是可以变的,并且举例如下:

1
2
String a = "abcd";
a = "abcdef";

这其实不是String本身变化,只是变量a指向heap堆的指针发生了变化,而String本身并没有发生变化,示意图如下:

String重新赋值示意图

为什么要设计成不可变

首先,先得清楚 final 这个关键字。 final的出现就是为了为了不想改变,而不想改变的理由有两点:设计(安全)或者效率。

  • 字符串常量池的需要。String 带有字符串常量池的属性,如果两个字符串one和two都指向 “something” 赋值,它们其实都指向同一个内存地址。这样在大量使用字符串的情况下,可以节省内存空间,提高效率。之所以能实现这个特性,String 的不可变是必要的(如果可变,那么一个改了,所有引用常量池这个string值都会改变)。
  • 允许String对象缓存HashCode。String 对象的哈希码被频繁使用,比如在HashMap 中。
  • 其次,为了安全。多线程安全:多个线程同时读一个资源,不会引发竞态条件,但是对资源做写操作就会有危险,这样保证String使用线程安全。url、反射所需要的参数等都是String类型,如果允许改变,会引起安全隐患(比如非法访问:如果String可变,那么可以在安全检测后,修改String值,导致非法访问)。

一定是不可变的吗?

由以上内容可知,String 是通过字符数组实现的,这个字符数组被final 修饰,因此不能重新指向其他内存区域,但是,我们可以针对这块内存区域改变值,即改变这个数组里面的内容,比如将 value[0] 的值由 ‘a’ 改成 ‘b’(当然这个过程要通过反射去实现)。

可能令你疑惑的操作方式

我们平日开发通常情况下少量的字符串拼接其实没太必要担心,例如:

String str = “aa”+”bb”+”cc”;

像这种没有变量的字符串,编译阶段就直接合成”aabbcc”了,然后看字符串常量池(下面会说到常量池)里有没有,有也直接引用,没有就在常量池中生成,返回引用。

如果String a = “123”;这种写法是会将 “123” 放入常量池的,但是如果使用 String b = new String(“123”); 则会在堆上分配空间存放。

但是如果:

1
2
3
String str1 = "aaa";
String str2 = "bbb";
String str = str1+str2;

则在编译的时候会优化成: StringBuilder sb = StringBuilder(String.valueOf(str1))).append(str2)

如果:

1
2
3
4
StringBuffer sb = new StringBuffer();
String s = null;
sb.append(s);
System.out.println(sb.length());

则会输出4,因为如果是null的话,则会拼接 “null”。

以上内容参考自:qingmengwuheng1 以及 岚之山

27、成员内部类、静态内部类、局部内部类和匿名内部类的理解

点击看答案

成员内部类

成员内部类定义在另一个类的内部,它是依赖外部类而存在的,也就是说如果要创建内部类的对象,前提是必须存在一个外部类的对象,以下是两种内部类使用情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TestClass{
public void haha(){
//典型用法:在当前外部内中直接使用内部类
InnerClass innerClass = new InnerClass();

//非典型用法:使用某个类的内部类
OutBean outBean = new OutBean();
OutBean.InnerBean = outBean.new InnerBean();
}

class InnerClass{
}

class OutBean{
class InnerBean{
}
}
}

注意代码中典型用法和非典型用法成员内部类对象的创建,说明了内部类对象的创建是依赖于外部类对象的,尤其是: outBean.new InnerBean() 这种写法。

成员内部类可以用private、protected、public 及包访问权限修饰,如果成员内部类被private修饰,则只能在外部内的内部访问;如果使用public修饰,则任何地方都能访问;如果使用protected修饰,则只能在同一个包下,或者继承外部类的情况下访问;如果是默认访问权限,则只能在同一个包下访问。

成员内部类可以无条件访问外部类所有的成员属性和方法(包括private的和static的),不过如果内部类和外部类有相同名称的变量或者方法时,优先访问内部类自己的,如果要访问外部类的可以如下写法:

外部类.this.成员变量(方法)

局部内部类

局部内部类是定义在一个方法或者一个作用域里面的类,它和成员内部类的区别在于它的访问尽心阿玉方法内或者作用域内。由于类似于局部变量,所以并不能有 public、protected、private 或者 static 修饰符的。示例如如下代码:

1
2
3
4
5
6
7
public People getWoman(){
class Woman extends People{//局部内部类
int age = 0;
}

return new Woman();
}

匿名内部类

匿名内部类我们使用很多,比如在实现点击监听的时候:

1
2
3
4
5
6
btnOk.setOnclickListener(new OnclickListener(){
@Override
public void onClick(){

}
});

匿名内部类是为一种没有构造器的类,大部分匿名内部类用于接口回调,一般来说,匿名内部类用于继承其他类或者接口,并不需要增加额外地方法,只是对继承方法的实现或者重写。

静态内部类

静态内部类定义在一个类里面,并且被static 修饰,并不需要依赖于外部类。这点和类的静态成员属性有点类似,并且它不能使用外部类的非static 成员变量或者方法。

深入理解内部类

1、为什么成员内部类可以无条件访问外部类成员?

因为编译器会默认为内部类构造器中添加一个参数,这个参数是外部类对象的一个引用,所以它能直接访问外部内的成员。这也从侧面说明成员内部类为什么要依赖于外部类的对象。

2、为什么局部内部类和匿名内部类只能访问局部final 变量?

我们首先看如下代码:

1
2
3
4
5
6
7
8
9
10
public void test(final int b){
final int a = 10;

new Thread(){
public void run(){
System.out.println(a);
System.out.println(b);
}
}.start();
}

如果把变量a或者b任意一个final 去掉,代码就编译不过。至于为什么,我们首先考虑一个问题:当test 方法执行完成后,变量a的生命周期结束了,而Thread对象的生命周期可能还没结束,那么在Thread的run方法中继续访问a就实现不了了,但是又要实现这样的效果,怎么办?Java中采用了 复制 手段来解决,也就是将a复制到Thread对象中。

也就是说,如果局部变量的值在编译期间就能确定,则直接在匿名内部类中创建一个拷贝;如果局部变量的值无法再编译期间确定,则通过构造器传参的方式来对拷贝进行初始化赋值

内部类的场景和好处

  • 每个内部类都能独立继承一个接口实现,所以无论外部类是否已经继承了某个实现,对内部类都没有影响。内部类使得多继承的解决方案变得完整
  • 方便将存在一定逻辑关系的类组织在一起,又可以对外隐藏
  • 方便编写事件驱动程序(如实现点击监听)

以上内容参考自:Matrix海 子

28、多维数组在内存上是怎么存储的

点击看答案

Java 中的多维数组就是通过一维数组来实现的,只不过这个一维数组中的元素还是多维数组,比如如下声明:

1
int[][][] array = new int[2][3][4];  

它实际上大致等同于如下代码:

1
2
3
4
5
6
7
int[][][] a = new int[2][][];  
for (int i = 0; i < a.length; i++) {
a[i] = new int[3][];
for (int j = 0; j < a[i].length; j++) {
a[i][j] = new int[4];
}
}

如果要自己用一维数组去实现二维(或者更多维)的数组,可以使用类似规律: k = j*(j-1)/2 + i -1 来计算出在一维数组中的下标 k 值。

以上代码参考自iteye中的博客

29、泛型

点击看答案

一个例子

一个很经典的例子,可以告诉我们为什么需要泛型:

1
2
3
4
5
6
7
8
List list = new ArrayList();
list.add("aaa");
list.add(100);

for(int i =0;i< list.size();i++) {
String item = (String)list.get(i);
Log.d("Test", "item = " + item);
}

上述例子for语句上面的操作都是合法的,但是在for语句里面,执行到i==1的时候,会报错(运行的时候报错):

java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

为了能够在编译期间就能发现这种问题,就可以使用泛型,如下所示:

List list = new ArrayList();

list.add(100);//编译阶段就会报错

所以,在被问到 ”我初始化一个List,但是没有指定类型,那么,我是不是可以添加任何类型的值?“,那么,答案是肯定的,可以添加任何类型的值,只要你能正确取出(在取的时候,知道每个位置存储的元素的类型),我自己写的例子如下:

1
2
3
4
5
6
7
8
9
10
11
List list = new ArrayList();
list.add("aaa");
list.add(100);

for(int i =0;i< list.size();i++) {
if(i == 0) {
System.out.println((String)list.get(i));
} else if(i == 1) {
System.out.println((int)list.get(i));
}
}

特性

泛型只在编译阶段有效,运行阶段会将特定类型擦除:

1
2
3
4
List<String> strList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();

//那么strlist.getClass 与 integerList.getClass 的结果是一样的(equals比较)

泛型的使用

泛型有三种使用方式,泛型类、泛型接口、泛型方法。一个普通的泛型类如下:

1
2
3
4
5
6
7
8
9
10
public class Test<T>{
private T key;
public Test(T key) {
this.key = key;
}

public T getKey(){
return key;
}
}

然后,我们日常的使用就是类似如下代码:

1
2
3
Test<String> testString = new Test<String>("123");
//或者
Test<Integer> testInt = new Test<Integer>(456);

我们定义了泛型类,我们就一定要传入泛型类型的实参么?想想我们最开始的那个例子,很显然并不是这样的。在使用反省的时候如果传入泛型实参,则会根据传入的泛型实参做相应限制,此时泛型才会起到应有的作用;如果不传入泛型类型的实参,则可以为任何类型。看如下例子都是合法的:

1
2
3
Test test1 = new Test("111");
Test test2 = new Test(222);
Test test3 = new Test(false);

这里要注意一点,泛型类型的参数只能是类类型,不能是简单类型

泛型接口

泛型的接口示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface Test<T> {
public T next();
}

//当然,实现的时候,可以不传入泛型实参
class TestImp<T> implements Test<T>{
public T next(){
return null;
}
}

//当然,你也可以传入实参
class TestImpl implements Test<String> {
public T next(){
return "";
}

泛型通配符

通配符一般使用 ? 代替具体的类型实参,注意这个 ? 是类型实参,不是类型形参。再直白一点,此处的 ? 和 Number、String、Integer 是一样的,都是一种实际类型,具体代码表示如下:

1
2
3
public void showKeyValue(Test<?> test) {
System.out.println("key is :" + test.getKey());
}

这样可以解决当具体类型不知道的时候,这个通配符就是 ? ;当操作类型时,不需要使用类型的具体功能时,那么可以用 ? 来表示未知类型。

泛型方法

泛型方法的定义相对复杂,泛型方法是指在调用方法的时候指明泛型的具体类型,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public <T> T func(Class<T> tClass) throws InstantiationException {
T instance = tClass.newInstance();
return instance;
}

//或者 返回类型为 void

public <T> void func(T t) {
System.out.println(t.toString());
}

//当然,方法的泛型可以和类的泛型不一样
class Test<T> {
//E 可以是任意类型,可以与类型T相同,也可以不同
public <E> void func(E t){

}
}

静态方法与泛型

静态方法有一种情况需要注意,那就是在类的静态方法中使用泛型:静态方法无法访问类上定义的泛型;如果静态方法操作的引用数据类型不确定的时候,必须要将泛型定义在方法上

1
2
3
4
5
6
7
public class StaticTest<T>{
//如果在类中定义使用泛型的静态方法,需要添加额外的泛型声明(将这个方法定义成泛型方法),即使静态方法要使用的泛型已经在类中的泛型生命果也不行

public static <T> void show(T t){

}
}

泛型上下边界

List<? extends Animal>,?表示的类型可以是Animal类型本身和Animal的子类。可以把Animal称为这个通配符(?)的上限(upper bound)。

<? super Type> 通配符 ? 表示它必须是Type本身,或是Type的父类

以上内容参考自 博客园上的博客简书上的博客

30、谈谈Java集合中那些线程安全的集合 & 实现原理

点击看答案

暂无

31、注解

点击看答案

在JAVASE中的注解有3个它们分别是:@Overried 重写,@Deprecated 不建议使用,@SupperssWarning 去除警告信息 。

注解格式如下:

1
2
3
public @interface 注解名称{
属性列表;
}

如果说注释是写给人看的,那么注解就是写给程序看的。它更像一个标签,贴在一个类、一个方法或者字段上。它的目的是为当前读取该注解的程序提供判断依据及少量附加信息。比如程序只要读到加了@Test的方法,就知道该方法是待测试方法。

@interface和interface从名字上看非常相似,我猜注解的本质是一个接口(当然,这是瞎猜)。为了验证这个猜测,我们做个实验。先按上面的格式写一个注解(暂时不附加属性):

注解类

之后,反编译:

注解类

我们发现,@interface变成了interface,而且自动继承了Annotation !

为了探究原理,首先我们看一下Overried 注解的底层实现:

1
2
3
4
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

其中Overried注解的上面有两行代码其中一行的修饰符为@Target和@Retention这两个注解是元注解,元注解用来修饰注解,我们来看看这两个注解的底层实现:

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
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Target {
/**
* Returns an array of the kinds of elements an annotation interface
* can be applied to.
* @return an array of the kinds of elements an annotation interface
* can be applied to
*/
ElementType[] value();
}



@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Retention {
/**
* Returns the retention policy.
* @return the retention policy
*/
RetentionPolicy value();
}

Target注解的类里面有一个属性名叫value,他是个数组类型,我们再看看Target注解的用法:

1
2
3
4
@Target({ElementType.METHOD,ElementType.TYPE,ElementType.FIELD})
public @interface huhu {

}

Target表示注解修饰的地方,常用的有3个分别是加载在类上,方法上或者属性上。分别为:ElementType.METHOD,ElementType.TYPE,ElementType.FIELD

Retention表示什么时候读取到这个注解,RetentionPolicy.SOURCE代表源文件读取注解,RetentionPolicy.Class代表编译后读取注解,RetentionPolicy.RUNTIME 代表运行时读取到注解。

以下以一个完整例子展示如何使用注解:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class JavaMain {
public static void main(String[] args) throws Exception {
// 获取类上的注解
Class<Demo> clazz = Demo.class;
Huhu annotationOnClass = clazz.getAnnotation(Huhu.class);
System.out.println(annotationOnClass.getValue());

// 获取成员变量上的注解
Field name = clazz.getField("name");
Huhu annotationOnField = name.getAnnotation(Huhu.class);
System.out.println(annotationOnField.getValue());

// 获取hello方法上的注解
Method hello = clazz.getMethod("hello", (Class<?>[]) null);
Huhu annotationOnMethod = hello.getAnnotation(Huhu.class);
System.out.println(annotationOnMethod.getValue());

// 获取defaultMethod方法上的注解
Method defaultMethod = clazz.getMethod("defaultMethod", (Class<?>[]) null);
Huhu annotationOnDefaultMethod = defaultMethod.getAnnotation(Huhu.class);
System.out.println(annotationOnDefaultMethod.getValue());
}


@Target({ElementType.METHOD,ElementType.FIELD,ElementType.TYPE})
@Retention(RetentionPolicy.SOURCE)
public @interface Huhu {
String getValue() default "no description";

}

@Huhu(getValue = "onClass")
public static class Demo{
@Huhu(getValue = "onFild")
public String name;

@Huhu(getValue = "onMethod")
public void hello(){}

@Huhu()//故意不指定,则会拿默认值
public void defaultMethod(){}
}
}

根据反射,可以获得各个位置的值,Class、Method、Field对象都有个getAnnotation()方法,可以获取各自位置上的注解信息。

以上内容参考自知乎、以及 简书上的博客

32、序列化

点击看答案

关于构造函数

反序列化时,并没有通过 Person 类的构造函数,不管是有参的还是无参的,它是根据序列化的数据创建的

关于序列化的部分

  • 被static和transient修饰成员无法按默认方式序列化。

  • 父类也需要实现Serializable接口才能把父类属性序列化。

  • 父类没有实现 Serializable 接口时,虚拟机是不会序列化父对象的,而一个 Java 对象的构造必须先有父对象,才有子对象,反序列化也不例外。所以反序列化时,为了构造父对象,只能调用父类的无参构造函数作为默认的父对象

关于同一对象多次序列化

如果执行2次 ObjectOutputStream..writeObject(obj); 则第二次写入对象时文件只增加了 5 字节,并且2次反序列化后两个对象是相等的,这是为什么呢?因为Java 序列化机制为了节省磁盘空间,具有特定的存储规则,当写入文件的为同一对象时,并不会再将对象的内容进行存储,而只是再次存储一份引用。

自定义序列化过程

虚拟机会试图调用对象类里的 writeObject ()和 readObject() 方法,进行用户自定义的序列化和反序列化,如果没有这样的方法,则默认调用是 ObjectOutputStream 的 defaultWriteObject() 方法以及 ObjectInputStream() 的 defaultReadObject 方法。由于可以自定义,使用writeObject()和 readObject() 方法,可以序列化static和transient修饰的成员

Externalizable完全定制序列化

以上内容参考自敲破苍穹的博客

1、是否可以在子线程更新UI

点击看答案

今天看到网上有人较真,说Android中,你可以在子线程更新UI,于是我写了如下测试代码:

1
2
3
4
5
6
7
8
9
10
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val textView = findViewById<TextView>(R.id.text_view)

val runnable = Runnable {
textView.setText("new text")
}

Thread(runnable).start()

在子线程中更新EditText的文字居然成功!其实,在主线程修改UI,这属于一个“建议”而不是“标准”,因为如果在子线程定义UI的修改,无法预料到UI会被如何修改。

一言以蔽之:View之所以不能在子线程做UI操作,是因为在 ViewRootImpl 里面会做线程检测,而在onCreate 的时候,ViewRootImpl 还没初始化。

settext的调用流程大概会经历如下步骤:

  • TextView 的 checkForRelayout() 方法
  • TextView 的 invalidate() 方法
  • View 的 invalidate() 方法
  • View 的 invalidateInternal() 方法
  • ViewGroup(ViewParent) 的 invalidateChild() 方法(不断loop取上一个节点的mParent,然后DecorView 的mParent 是 ViewRootImpl )
  • 即一直调用到 ViewRootImpl 的 invalidateChild()
  • 最终在 ViewRootImpl 中会 checkThread()检查线程

在ViewGroup 的 invalidateChild() 中,会判断 AttachInfo 是否为空,而在Activity 的 onCreate的时候,Activity 还在初始化,ImageView的mAttachInfo 是空的,所以在ViewGroup 中就直接没执行下去了,而settext 早就发生了,因此,就略过了检查线程这一阶段。

2、延伸-子线程更新UI骚操作-在子线程启动 Dialog

点击看答案

我们可以看下 ViewRootImpl 中的 构造函数 和 checkThread() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//构造函数
public ViewRootImpl(Context context, Display display) {
mContext = context;
mWindowSession = WindowManagerGlobal.getWindowSession();
mDisplay = display;
mBasePackageName = context.getBasePackageName();
mThread = Thread.currentThread();
...
}


//checkThread 方法
void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}

可以发现,这里并不是要求什么主线程(UI线程),而是只要当前线程和ViewRootImpl/Window/View 的创建线程是同一个线程就ok,所以,只要在子线程中创建的View,就能在那个子线程更新UI,试试如下代码(我自己亲测可以运行):

1
2
3
4
5
6
7
8
val runnable = Runnable {
Looper.prepare()
val dialog = MyDialog(this@MainActivity)
dialog.show()
Looper.loop()
}

Thread(runnable).start()

延伸-在子线程更新UI的另一个解释:

在子线程自己获取 WindowManager (MainActivity.this.getWindowManager) ,然后创建TextView并add到window中,就能展示的。因为呢,我们并不是说限制主线程,而是说要求更新ui的线程和 创建ViewRoot是否属于该线程

CSDN

3、LruCache 原理

点击看答案

LRU(Least Recently Used,最近最少使用) 缓存算法就是为缓存设计的,它的思想就是当缓存满时,会优先淘汰那些最近最少使用的缓存对象。LruCache 就是Android 基于 LRU 算法给的一个缓存类。

LruCache 的核心就是维护一个缓存对象列表,其中对象按照访问顺序实现的,即一致没访问的对象,将放在队尾,首先被淘汰,最近访问的对象放在队头,最后淘汰。如下图所示:

LruCache淘汰过程

这里面的队列是由LinkedHashMap 来维护的,前面已经介绍过它的实现原理了,他有个构造函数是这样的:

public LinkedHashMap(int initialCapacity,float loadFactor,boolean accessOrder)

最后一个参数 accessOrder 用来表示LinkedHashMap 中双向链表的顺序是插入顺序还是访问顺序,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@JvmStatic
fun main(args: Array<String>) {

val linkedHashMap = LinkedHashMap<Int,Int>(0,0.75f,true)

linkedHashMap[0] = 0
linkedHashMap[1] = 1
linkedHashMap[2] = 2
linkedHashMap[3] = 3
linkedHashMap[4] = 4
linkedHashMap[5] = 5

val ar1 = linkedHashMap[1]
val ar2 = linkedHashMap[2]

linkedHashMap.forEach { (t, u) ->
println("key = $t,value = $u \n")
}
}

如果在构造LinkedHashMap 的时候,accessOrder 为false ,则会依次打印:

key = 0,value = 0
key = 1,value = 1
key = 2,value = 2
key = 3,value = 3
key = 4,value = 4
key = 5,value = 5

如果为true,则会打印:

key = 0,value = 0
key = 3,value = 3
key = 4,value = 4
key = 5,value = 5
key = 1,value = 1
key = 2,value = 2

这是因为为true时,会导致最近访问的最后输出,那么这就刚好满足LRU 缓存算法的思想,所以LruCache 的巧妙实现,就是利用了 LinkedHashMap 的这个功能。 所以LruCache 的构造函数为:

1
2
3
4
5
6
7
public LruCache(int maxSize) {
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize <= 0");
}
this.maxSize = maxSize;
this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
}

所以LruCache 后续的操作就比较好理解了:

  • 在put 新元素的时候,首先判断 key 和value 都不能为空,之后更新缓存大小;如果之前这个key 有值,则替换这个值,由于已经先前更新过缓存大小,此时要把老的value所占缓存大小减去。最后调整缓存大小,如果缓存大小超过阈值,则依次取出LinkedHashMap 中取出key-value 删除,直到小于阈值为止。
  • get 的时候,判断 key 是否为 null ,不能为 null 。get操作之后,接着把这个节点删除,再把这个节点添加(头插法,会插入到头部,这样保证顺序)。

4、聊聊handler机制? 一个线程是否只有一个Looper?如何保证一个线程只有一个Looper?

点击看答案

Handler 初始化的时候会通过 Looper.myLooper(实际上只是返回了 sThreadLocal.get()) 获取当前线程的Looper。之后通过 looper 获取当前线程的 mQueue 。
当然,如果在子线程中new Handler ,基本上会提示 您还未执行Looper.prepare();
Looper.prepare() 会判断当前 sThreadLocal.get()是否已经存在了,如果已经存在了,就会提示 “一个线程只能有一个Looper”(注意,就是在这里保证了一个线程只有一个Looper)。Looper.prepare() 只是执行了sThreadLocal.set(new Looper()),在这里给线程设置了Looper。
而在主线程中,ActivityThread 的main 方法中会执行 Looper.prepareMainLooper()来设置Looper,故我们可以直接new Handler,而在子线程中要手动Looper.prepare()才行,并且还要Looper.loop(),让消息循环。
接下来就是 handler 的 sendMessage 和post 方法,其实两个方法都是调用 sendMessageDelay 方法,只不过post方法首先将 Runnable封装成Message,变成Message的CallBack。
在最终send的时候,Message会持有handler的引用,叫做target,之后,message被丢到handler所持有的MessageQueue中。
之后,在主线程中,Looper一直在循环,取出queue中的Message,然后执行message.target.dispatchMessage,在这个方法中,最终会调用到我们写handler时候覆写的 handleMessage 方法。至此,整个流程走完。

5、Handler 的postDelay 是怎么实现的?

点击看答案

可能用举例子的方式容易懂一些:

  1. postDelay 一个10秒的 RunableA 到 MessageQueue,MessageQueue 会调用 nativePollOnce 阻塞线程。
  2. 接着post 一个 RunnableB 到 MessageQueue,由于 RunnableB 没有延时,因此when 时间比 RunableA 小,因此被插入在队头,然后调用nativeWake 方法唤醒 线程 。
  3. 唤醒后,MessageQueue.next() 方法继续执行,读取到第一个消息 RunnableB,由于没有延时,直接交给Looper。
  4. Looper 处理完B 后,再次调用 MessageQueue.next() 方法,这时候 RunableA 还没到时间,这时候调用 nativePollOnce 阻塞。
  5. 这个状态直到阻塞时间到或者下一次有Message 进队。

至于为什么 handler.postDelay并不是先等待一定的时间再放入到MessageQueue中,因为那样的话会需要多个定时器,增加开销。

以上内容源自阅读源码以及 网上博客

附:如何移除Handler的Message?有啥坑吗?

点击看答案

如果移除Message,有两种方式:

  • 根据 Message 的 what 来移除:handler.removeMessages(what),handler.removeMessages(what.obj),当然,后者的obj如果为空,就会移除所有的 Message
  • 根据token移除:handler.removeCallbacksAndMessages(token),当然,如果token为空,就会移除所有 Message (比如在Activity的onDestroy 中有时候为了避免内存泄露会移除),则只需要传入 null 即可。

handler 的延时操作有两种:

  • handler.postDelayed(runnable, 10000);
  • handler.sendMessageDelay(0, 10000);

但是,如果我们混合使用二者,在移除的时候可能会出现意想不到的问题,比如如下代码:

1
2
3
4
5
6
7
8
9
//创建handler

class MyHandler extends Handler{
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
Log.d(TAG, "handleMessage: "+msg.what);
}
}

创建runnable:

1
2
3
4
5
6
7
8
9
10
11
12
13
Runnable runnable1 = new Runnable() {
@Override
public void run() {
Log.d(TAG, "run: 1");
}
};

Runnable runnable2 = new Runnable() {
@Override
public void run() {
Log.d(TAG, "run: 2");
}
};

执行:

1
2
3
4
5
6
7
MyHandler handler = new MyHandler();
handler.postDelayed(runnable1,1000);
handler.postDelayed(runnable2,1000);
handler.sendEmptyMessageDelayed(0,1000);
handler.sendEmptyMessageDelayed(1,1000);
handler.sendEmptyMessageDelayed(2,1000);
//handler.removeMessages(0);

如果屏蔽最后一行,就会输出:

1
2
3
4
5
run: 1
run: 2
handleMessage: 0
handleMessage: 1
handleMessage: 2

但是如果不屏蔽,就只会输出:

1
2
handleMessage: 1
handleMessage: 2

意味着两个Runnable 也被移除了!这是咋回事?原来,handler 的 postDelay 功能也是用 sendMessageDelayed 方法去实现的!这当然需要构造 Message 对象咯,然而也仅仅只是 Message m = Message.obtain();m.callback = r; 意味着会新建一个 Message ,而新建的 Message 的what值默认为0 !问题找到了,那么以后使用remove的时候需要注意什么呢?主要两点:

  • 自定义Handler 处理 msg.what 的时候,what的值不要使用默认值0
  • 同一个Handler 不要同时使用 postDelayed() 和 sendMessageDelayed()

以上内容参考自csdn的博客

6、RecyclerView 的性能优化

点击看答案

有以下几种方式能做到RecyclerView的优化:

  • 数据处理与视图绑定分离。

    bindViewHolder 方法是在ui线程执行的,而远端拉取数据肯定是要放在子线程的,所以我们在拉取数据之后做一些预处理后再丢给adapter,防止在 bindViewHolder 方法中再去处理时间比较、保留小数位数之类的操作。

  • 布局优化

    1、减少item布局层次 2、减少没必要的xml文件的inflate,可以使用new View()等方式(shape类型的xml 也一样)。

  • 可能的话,为 RecyclerView 设置 setHasFixedSize(true)

    这个方法的主要作用就是设置高度,来避免 rv 的 measure 和 layout 操作。比如一个垂直滚动的rv,height属性设置为 wrap_content,最初的数据集只有3条,全部展示出来也不能使rv撑满,如果我们通过notifyItemRangeInserted 添加数据,那么如果你设置了 setHasFixedSize 为true的情况下,rv高度是不会改变的。体现在diamante中就是requestLayout方法的调用。

  • 减少itemView的监听器创建

    我们无需对每一个item 都采用匿名内部类的方式添加监听,,而应该公用一个 xxListener 对象,通过id来区分不同操作,避免频繁创建对象带来资源消耗。

  • 加大RecyclerView的缓存

    通过设置 setItemViewCacheSize、setDrawingCacheEnabled 以及setDrawingCacheQuality 等方法增加缓存空间,以空间换时间,提升流畅性。

  • 滑动过程中停止加载

  • 使用 DiffUtil 工具

    DiffUtil 工具类用来判断新数据和旧数据的差别,从而进行局部刷新,我们只需要在原来调用 mAdapter.notifiyDataSetChanged() 的地方改成下面这样:
    DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffCallBack(oldDatas, newDatas), true);
    diffResult.dispatchUpdatesTo(mAdapter);
    最终,mAdapter 会通过调用 notifyItemRangeInserted、notifyItemRangeRemoved 等方法进行局部刷新。

  • 公用RecycledViewPool

    在嵌套的 rv 中,如果子 rv 具有相同的adapter ,那么可以设置 :
    RecyclerView.setRecycledViewPool(pool) 来公用RecycledViewPool。

  • RecyclerView数据预取

    默认是开启的,跟我们没关系。具体原理是,当上一帧交给gpu之后,cpu就一直处于空闲状态,需要等待下一帧才会数据处理,所以rv做了个预判,rv会取接下来可能要显示的item,在下一帧到来之前把数据提前处理好,不过呢,这个预判是不一定准确的。

以上内容参考 mandypig编码前线 以及 Blankj

7、引申-如何保存嵌套rv中的滑动装填

比如嵌套的rv这时候左滑到第三个,这时候外层的rv滑动很长距离,当前这个嵌套的rv已经看不见了,划出好远了,如何在嵌套rv再次可见的时候,恢复当时滑到的第三个。

其实 Linearlayoutmanager 中有对应的 onSaveInstanceState 和 onRestoreInstanceState 方法来处理保存和恢复机制。

8、图片加载优化

  1. 假设通过ImageView 显示图片,很多时候ImageView 没有原始图片尺寸那么大,把整个图片加载进内存再设置给ImageView则没必要,这时候可以在加载图片时采用低采样率加载进来。
  2. 与后端配合,在url 后面接上需要的图片尺寸。(目前就是这么做)

9、Activity生命周期

点击看答案
  1. onCreate():当 Activity 第一次创建时会被调用
  2. onRestart():表示Activity正在重新启动。一般情况下,当当前Activity从不可见重新变为可见状态时,onRestart就会被调用。
  3. onStart(): Activity已经出现了,但是还没有出现在前台,无法与用户交互。这个时候可以理解为Activity已经显示出来,但是我们还看不到
  4. onResume():表示Activity已经可见了,并且出现在前台并开始活动。需要和onStart()对比,onStart的时候Activity还在后台,onResume的时候Activity才显示到前台
  5. onPause():表示 Activity仍可见,只是不可交互
  6. onStop():表示Activity不可见,位于后台
  7. onDestory():表示Activity即将销毁,这是Activity生命周期的最后一个回调,可以做一些回收工作和最终的资源回收

10、延伸-生命周期几种普通情况

点击看答案

从 A 页面Activity 跳转到 B 页面Activity:

一、 启动过程会经历:

A:onPause-> B:onCreat-> B:onStart-> B:onResume-> A:onStop

所以我们可以得出结论:

  • 在A 的onPause 中不要执行耗时操作,否则会影响新打开的B,因为当前A 的 onPause 必须执行完,B 的onResume 才会执行。
  • 等 B 的onResume 执行后,A 才完全被覆盖看不见,故,B的onResume 调用完后,A的Stop 才调用。

二、按返回键返回到A:

B:onPause-> A:onRestart-> A:onStart-> A:onResume-> B:onStop-> B:onDestroy

我们得出结论:

  • B 首先让出交互权力
  • A 到前台(onResume)后 ,B才退到后台,B才调用 onStop

三、按Home 键:

onPause -> onStop,即让出交互,退到后台

反之,此时再点击图标唤起:

onRestart-> onStart-> onResume,即Activity 还在,只需要重新可见即可

11、Activity的启动模式

点击看答案
  • standard:标准启动。
  • singleTop(栈顶复用模式):在当前栈顶就复用,否则新建。
  • singleTask(栈内复用模式):在当前栈存在有实例,如果在栈顶,直接使用;如果不在栈顶,将该实例之上的Activity全部出栈。
  • singleInstance(单例模式):只要有这个实例,不管在哪个栈,都复用之;否则,在新的栈创建实例。

12、Fragment 为何不推荐使用构造方法传递参数?

因为activity 给fragment 传递数据时是通过 setArguments 来传递。如果采用构造方法传递,在诸如横竖屏切换的时候会调用fragment 的空的构造函数,造成数据丢失。

13、Context 理解

参考以前写的博客:https://glassx.gitee.io/2019/12/06/Android%E8%BF%9B%E9%98%B6%E8%A7%A3%E5%AF%86-%E7%AC%AC5%E7%AB%A0/

14、Android 全局异常处理

在Application 中为 Thread 设置ExceptionHandler 即可。

参考:全局异常处理

15、谈谈你对Application类的理解

点击看答案

说说对什么的理解,就是考察这个东西会不会用,重点有没有什么坑。有以下几点需要注意:

  • Application 在一个虚拟机里面只有一个实例。这里不是说一个App只有一个实例,因为一个App 可能有多个进程,也就是多个虚拟机,这种情况下,每个虚拟机中都会存在一个Application 对象。
  • Application 本质是一个Context ,继承自 ContextWrapper。
  • Application 有 MultiDexApplication 子类,这个子类可以用来解决 65535 问题,完成多Dex 打包配置相关工作。
  • 在Application 的onCreate 方法中我们会进行各种初始化,如图片加载库、log 等,但是最好别在里面进行太多耗时操作,这会影响App启动速度,可以使用异步、懒加载、延时加载等策略来减少影响。
  • 通过Context.getApplicationContext ,不论是从Activity 中、Service中获取,都是同一个Application 对象。
  • 在低内存情况下,Application 可能会被销毁,从而导致保存在Application 中的数据错乱,所以要注意判空或者选择其他方式保存数据。
  • Application 中几个有用的回调如 onLowMemory(一般来说,这个回调的时候,background进程已经都被kill掉了) 、onTrimMemory(提供多个 Level 的告警,能够更精细化控制) ,在内存紧张的时候,在这些回调里面关闭数据库连接、移除图片缓存等方式来降低内存,降低被回收的风险。
  • Application 的生命周期和虚拟机一样长,所以单例或者静态变量的初始化一定要使用Application 的Context 进行初始化,防止内存泄漏。

以上内容可以参考这个链接、以及官方文档

16、Android 中进程通信方式

可以翻看以前的读书笔记

17、Binder 原理

点击看答案

为什么要使用Binder

  1. 性能方面;Binder 数据拷贝只需要一次,而管道、Socket 等都需要2次,共享内存不需要拷贝,但是实现方式比较复杂。
  2. 安全方面;传统的进程通信方式对于通信双方没有严格限制,而Binder 机制从协议本身就支持对通信双方做身份校验,所以大大提升安全性。

IPC 原理

每个Android 进程,只能运行在自己进程所拥有的虚拟地址空间。例如,对应4G的虚拟地址空间,其中3G是用户空间,1G是内核空间,当然,内核空间大小是可以通过参数配置的。对于用户空间,进程间是不能共享的,而内核空间是可以共享的。Client 进程向Server 进程通信,恰恰是利用进程间内核空间来完成底层通信工作的。

跨进程使用服务的流程

  1. Client 通过Server 的代理接口,对Server 进行调用。
  2. 代理接口中定义的方法与Server 中定义的方法是一一对应的。
  3. Client 调用某个代理中的方法时,代理会将Client 传递的参数打包成 Parcel 对象。
  4. 代理将Parcel 发送给内核中的 Binder Driver。
  5. Server 读取Binder Driver 中的请求数据,解包 Parcel 对象,处理并返回。
  6. 整个调用过程是一个同步过程,在Server 处理的时候,Client 将会Block 住。故Client 调用过程不应该在主线程。

整个流程示意图如下所示:

IPC使用server的过程

关于IPC,还可以参考以前的文章

以上内容参考自:进程间通信

以下内容摘自《深入理解Android:卷II》:

Binder 有两种调用方式: 阻塞调用方式 和 非阻塞方式。前者 调用方(客户端)会阻塞,直到服务端返回结果,这种方式和普通的函数调用是一样的;后者调用方只要把请求发送到Binder驱动即可返回,但一般还会向服务端发送一个回调(同样是跨进程的Binder调用),不用等待服务端的结果,一旦服务端处理了该请求,就会调用此回调函数来通知客户端处理结果。

Handler中looper会阻塞,唤醒的时候是通过 pipe 发送 w 来唤醒的

在2.3以前,我们只可以从Java层向 MessageQueue 添加消息,但在2.3以后,MessageQueue 的核心部分下移到Native层,所以有时候cpu并不是很忙,但是你的sendMessage 又是等了很久才被处理,这有可能就是在处理 Native 层的 Message。

18、延伸-Android为什么要设计出Bundle而不是直接使用HashMap来进行数据传递

点击看答案
  • Bundle 内部是由ArrayMap 实现的,我们知道,ArrayMap 内部实现原理是两个数组,在添加、删除、查找 数据时,都会使用二分查找法,在数据量较小的情况下,相对 HashMap 而言,在效率相差不太大的情况下,更节省内存(HashMap的Entry Array 占用更多内存,并且没用到的会导致浪费)。而我们在Android中使用Bundle 传递数据都是比较少的,因此Bundle 更有效率。
  • Android 中如果使用Intent 携带数据的话,需要数据基本类型或者是可序列化类型,HashMap 使用Serializeble 进行序列化,而Bundle 是实现了 Parcelable 进行序列化。在Android 平台中,更推荐使用 Parcelable 进行序列化,因为更少的 io 操作(但同时使用更加复杂)。

以上内容参考zhaokaiqiang的博客

19、Android中IPC通信的方式有哪些?使用场景是什么

参考以前写的读书笔记即可

20、SharedPreference 解析

点击看答案

获取SharedPreference 对象

大概有3种方式获取:

  • 通过Context 的 getSharedPreference() 方法,指定name 和 mode;
  • 通过 Activity 的 getPreferences() 方法,它其实最终还是调用的 Context 的 getSharedPreferences() 方法,只不过我们只需要传mode参数,因为已经在方法中将 Activity 的类名作为name了;
  • 通过 PreferenceManager 的getDefaultSharedPreferences() 方法,目前基本上已经废弃

获取 SharedPreference 对象时,如果有没有存在这个xml文件,则创建,否则读取。在低于4.4 的版本上,如果name 为 null ,则会自动设置为 “null”。一直等这个 xml 文件加载解析完成,才会返回 SharedPreference 对象。

获取xml 过程中,首先会读取 ContextImpl 中的 sSharedPrefsCache 缓存:

private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;

由于sSharedPrefsCache 是static 的,并且 Android 中所有系统都使用这一个 ContextImpl 类,所以对于所有的app而言,都公用这一个 sSharedPrefsCache,因此可以理解为,系统启动后,如果有哪个应用使用过 sSharedPrefsCache ,那么它一直会留在内存中,直到系统关闭或者重启。

根据packageName ,可以从 sSharedPrefsCache 中获取当前应用的 ArrayMap<File, SharedPreferencesImpl> 列表,我们知道,根据不同的name,在文件中都会生成不同的xml 形式的 file。我们知道,SharedPreference 的xml 文件存储在 data/{packageName}/shared_prefs 目录下,所以我们name 就能获得 file 文件的路径,进而获取到这个xml 的 File 对象。根据这个对象,我们可以获取到 SharedPreferencesImpl 对象。在SharedPreferencesImpl中存在 Map<String, Object> 类型的 mMap 保存了xml 中key-value值 (解析完xml 后将值存入其中)。所以我们在正式使用的时候,实际上是从内存中读取的。

在解析这个 xml 过程中,SharedPreferenceImpl(SharedPreference 接口的实现类) 一直都是加锁的,在这个锁定状态下,我们无法调用它的 commit 和 apply 方法(处于wait状态),直到这个解析完成,就会执行 notifyAll 方法。

SharedPreference 的值获取

我们以 getBoolean 方法为例:

1
2
3
4
5
6
7
public boolean getBoolean(String key, boolean defValue) {
synchronized (mLock) {
awaitLoadedLocked();
Boolean v = (Boolean)mMap.get(key);
return v != null ? v : defValue;
}
}

可以看到在获取之前,首先加锁,因此这过程是线程安全的,之后 awaitLoadedLocked() 一直在等待(前面说的,在getSharedPreference() 过程中,在xml 解析完成return 之前,一直都会加锁的,完成后就会notifyAll),直到xml 文件解析完成。可以看看 awaitLoadedLocked 的源码,可以看到它的wait过程:

1
2
3
4
5
6
7
8
private void awaitLoadedLocked() {
...
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}

SharePreferences内部类Editor

我们来看 SharedPreference 的 edit() 方法:

1
2
3
4
5
6
7
8
public Editor edit() {

synchronized (mLock) {
awaitLoadedLocked();
}

return new EditorImpl();
}

可以看到,它也得 awaitLoadedLocked() 等待SharedPreference 准备完成。从这里还可以知道,每次 edit() 都会 new 一个 EditorImpl 对象,因此,不要频繁edit() 操作。 Editor 的具体实现是 EditorImpl 。我们可以粗略地看下它的源码:

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
public final class EditorImpl implements Editor {
private final Object mEditorLock = new Object();

@GuardedBy("mEditorLock")
private final Map<String, Object> mModified = new HashMap<>();

@GuardedBy("mEditorLock")
private boolean mClear = false;

//这里只写这一个put 操作,其他的 putXXX操作基本上是一样的
@Override
public Editor putBoolean(String key, boolean value) {
synchronized (mEditorLock) {
mModified.put(key, value);
return this;
}
}

@Override
public Editor remove(String key) {
synchronized (mEditorLock) {
mModified.put(key, this);
return this;
}
}
}

可以看到,我们的put 、remove 之类的操作,只是修改了 hashmap中的值,并没有存入到 SharedPreference 中,通过我们平时使用知道,要在 commit 或者 apply 方法中来生效。

改动提交到 SharedPreference

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public boolean commit() {
//1.先通过commitToMemory方法提交到内存
MemoryCommitResult mcr = commitToMemory();
//2.写文件操作
SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null);
try {
//阻塞等待写操作完成,UI操作需要注意!!!所以如果不关心返回值可以考虑用apply替代,具体原因等会分析apply就明白了。
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
}
//3.通知数据发生变化了
notifyListeners(mcr);
//4.返回写文件是否成功状态
return mcr.writeToDiskResult;
}

首先通过 commitToMemory 提交到内存,之后,直接在调用commit() 方法的线程中将数据写入文件。在真正写文件的时候,采用了数据库的事务思想,因为它有个 backfile 的备份文件。

接下来分析apply 方法:

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
30
31
public void apply() {
//有了上面commit分析,这个雷同,写数据到内存,返回数据结构
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
public void run() {
try {
//等待写文件结束
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
}
};

QueuedWork.add(awaitCommit);
//一个收尾的Runnable
Runnable postWriteRunnable = new Runnable() {
public void run() {
awaitCommit.run();
QueuedWork.remove(awaitCommit);
}
};
//这个上面commit已经分析过的,这里postWriteRunnable不为null,所以会在一个新的线程池调运postWriteRunnable的run方法
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

// Okay to notify the listeners before it's hit disk
// because the listeners should always get the same
// SharedPreferences instance back, which has the
// changes reflected in memory.
//通知变化
notifyListeners(mcr);
}

在子线程中提交了这个写任务,这个任务是通过handler去post的,而这个handler 初始化的Looper 是从 HandlerThread 中获取到的,所以最终还是相当于在一个个的apply 提交交给了HandlerThread 去操作,即在单线程的子线程执行。

使用时注意

  • 不要存储超大的key或者value

获取一个sp的时候,会把它的整个xml 文件都加载进来,如果太大,比如说 100k,那就会耗费很长的时间。如果为了读取一个boolean 配置,要把整个100k文件加载进来,是很不合理的,会引起频繁gc,和大的内存占用,所以我们应该只要存储很轻量的数据。 还有,我们知道,在getBoolean 或者其他getXXX 方法的时候,会要等待SharedPreference 加载完成,况且,在xml 加载过程中,有多个地方加锁。在加载完成后,getXXX 操作才能执行,否则一直在等待,这过程的阻塞可能引起界面卡顿和掉帧。 所以我们可以在super.onCreate() 之前,可以先执行 getPreference。

  • 不要在sp中存储 JSON 这种特殊符号很多的 value

这么做不是不可以,而是如果这个json很大,就会涉及很多转义(其实html 也会有这情况),带来很多&这种特殊符号,引发额外地字符串拼接以及函数调用开销。

  • 多次edit() 和 apply()

通过以上的分析我们也知道了,每次 edit() 操作都会 new 一个 EditorImpl,这是一点。还有,经过上次我们知道,每次 apply 会往 HandlerThread 中post 一个 Runnable,然后他们会在单线程中依次执行。可能说到这里还没觉得有什么,但是我告诉你这会导致卡顿,不可思议吧?在子线程操作的,怎么可能导致卡顿呢?但是我们看 ActivityThread 源码,执行 handleStopActivity 的时候:

1
2
3
4
5
6
7
8
9
10
private void handleStopActivity(IBinder token, boolean show, int configChanges, int seq) {

// 省略无关。。
// Make sure any pending writes are now committed.
if (!r.isPreHoneycomb()) {
QueuedWork.waitToFinish();
}

// 省略无关。。
}

就是在较老的Android版本(api 11 之前),会等待 apply 提交的那些 runnable 执行完了才能退出,如果这个时间过长,会导致anr。

  • 不要用于跨进程

Android官方也不建议使用SharedPreference 跨进程(已经@deprecated),而建议使用provider。因为它并不是在所有进程上都是可靠的。并且,它通过 MODE_MULTI_PROCESS 这个标志位来实现多进程标记,其实也只是如果sp已经读到内存了,再次获取这个sp 时,如果有这个标志位,就会重新获取一遍文件。

总结一下

  1. 不要存放大的key和value,可能引起页面卡顿,频繁gc
  2. 毫不相干的配置项不要丢在一起,文件越大越慢。这样,用户没有到达的页面的sp可以不加载进来。
  3. 读取频繁的key和不易变动的key尽量不要放在一起。
  4. 不要频繁 edit 和 apply ,尽量批量修改一起提交
  5. 尽量不要存放 json 和 html ,防止不必要的转义
  6. 不要指望用sp 来跨进程通信

以上内容经过自己看代码,以及参考自上善若水、以及维数不多

21、常见内存泄漏场景:

点击看答案
  • 资源性对象未关闭

如 File、Cursor、stream等资源,他们的缓存不只存在java虚拟机内,还存在虚拟机外,仅仅把对象置为null而不关闭,就会引起内存泄漏

  • 单例造成的内存泄漏

单例的静态性使得其生命周期跟app的生命周期一样长,如果使用不恰当(比如引用了非Application 的 Context)的话,很容易造成内存泄漏。

  • 注册对象未注销

观察者模式的注册,在不使用的时候未注销,就会导致,如在Activity中监听电话服务,定义PhoneStateListener注册到TelphoneManager服务中,如果忘记注销,会导致Activity无法被Gc回收。

  • 非静态内部类创建静态实例

首先,非静态内部类会持有外部类的引用。其次,创建的静态实例生命周期和应用的一样长。这样就导致了该静态实例一直会持有该外部类的引用,导致外部类内存资源不能正常回收。

  • 匿名内部类和异步线程
1
2
3
4
5
6
7
8
9
10
11
public class MainActivity extends Activity {
...
Runnable ref1 = new MyRunable();
Runnable ref2 = new Runnable() {
@Override
public void run() {

}
};
...
}

上述代码中,ref2的内部类会持有MainActivity 的实例,此时引入一个异步线程,如果此线程与MainActivity 生命周期不一致,就造成MainActivity 泄漏。

  • Handler造成的内存泄漏。

非静态Handler默认持有外部Activity的引用,退出Activity时,如果Looper中还有Message,就会导致Activity无法回收,可以(1)将Handler设置为静态,并且弱引用持有的对象 (2)Activity 的onDestroy的时候,一处消息队列的消息 来解决内存泄漏。

  • 容器中的对象没有清理

容器里的对象在不需要的时候,要及时移除,使其正确及时地被回收。

以上内容参考自:内存泄漏场景

22、延伸-内存泄露检测工具

项目中使用 LeakCanary,参考以前的 LeakCanary 源码分析 即可

23、Android应用程序启动过程

点击看答案

参考以前的读书笔记即可

24、Apk 安装的步骤

点击看答案

分析PMS(PackageManagerService) 就能知道这个过程,总体而言有这几个步骤:

  1. 首先判断安装源,诸如adb/shell/all_user 等
  2. 将apk 文件复制到 /data/app 目录
  3. 解析apk 信息,包括签名校验、四大组件的注册等
  4. dexopt 操作,优化apk中的.dex文件,对于dalvik 虚拟机,dexopt 就是优化操作;对于art 虚拟机,dexopt 就是将.dex翻译成oat文件。
  5. 更新权限信息:将app所有权限几率下来更新到PMS 中,并判定是否授予该app 请求的权限。
  6. 安装完成,发送 Intent.ACTION_PACKAGE_ADDED 广播

以上内容参考自:apk安装步骤

25、ANR异常发生条件?如何分析ANR?

点击看答案

ANR 发生条件:

  • 5s内没有响应用户输入事件
  • 10s内广播接收器没有处理完毕
  • 20s内服务没有处理完毕

ANR 时,系统做了什么

  1. 弹窗
  2. 将ANR 信息输出到 /data/anr/traces.txt 文件中(无需root 就能通过 adb pull 命令拷贝出来)
  3. 将ANR 信息输出到 Logcat 中(包含PID、Reason、CPU负载 等)

以上内容参考自: very_on

26、Android 热修复原理

点击看答案
  • DexClassLoder 可以用来从 .jar 和 .apk 类型的文件内部加载classes.dex 文件。用来执行非安装的程序代码。
  • 两个dex 中存在相同的 class 文件,则会从第一个dex 中找,找到了直接返回,第二个dex 中的class 永远不会被加载进来。
  • 阻止引用类被打上 CLASS_ISPREVERIFIED 标志。

在虚拟机启动的时候,如果verify 选项打开,static、private方法、构造函数等 中的直接引用到的类都在同一个dex文件中,那么该类就会被打上 CLASS_ISPREVERIFIED 标志。

注意,是阻止引用这的类,也就是说,假设有类叫做 LoadBugClass ,在其内部引用了 BugClass,在发布过程中发现 BugClass 有编写错误,那么想要发布一个新的 BugClass ,那么就要阻止 LoadBugClass 这个类被打上 CLASS_ISPREVERIFIED 的标志。而这个标志是在 apk 安装的时候,优化成odex 的阶段被添加的。所以在生成apk之前就要阻止 CLASS_ISPREVERIFIED。

以上内容参考自:热修复原理

27、插件化技术原理

点击看答案

插件化就是让我们应用不必把所有的内容都放在一个apk中,可以把一些功能和逻辑单独抽出来放在插件apk中,然后主apk 按需调用。一来可以让主apk体积更小,二来可以做到热插拔,动态化。

插件化技术基础:

  • DexClassLoader,想要实现加载外部的dex 来实现热部署,必然要把其中的class 文件加载到内存。DexClassLoader 能做到加载.jar 和 .apk 文件中的 class 文件。
  • Java 反射:因为插件apk 与宿主apk 不再一个apk 内,那么一些类的访问必然要通过反射进行获取。
  • 插件资源访问:res 里每个资源都会在R.java里生成一个Integer 类型的id,app 启动时会把R文件注册到当前的上下文环境,我们在代码中以R文件方式访问资源正是通过这些id访问。然而,插件的R.java并没有注册到当前的上下文,所以也就无法通过id使用。

我们可以通过 addAssetPath 方法重新生成一个新的 Resource 对象来保存插件中的资源,避免冲突。

  • 代理模式:无论是通过activity代理还是通过DroidPlugin 去hook activity 启动过程来启动activity的方式,都是对代理模式的应用。在前一种方式中,虽然加载进来了Activity 等组件,但也仅仅是作为一个普通对象而存在,并没有在AndroidManifest中注册,没有生命周期回调。这时候通过代理即可。

以上内容参考自: http://www.androidos.net.cn/book/android-road/android/advance/plugin.html、virtualAPK 实现方式可以参考这里

28、轮播图实现原理?

点击看答案

原理:

  • 如果只有一张图,则不作处理

  • 如果有n张图,则在 ViewPager 的adapter中做如下处理:

    1. getAccount 返回 n * 10000
    2. instantiateItem 的时候,position 需要对 n 求余

源自项目代码

29、ListView 原理

点击看答案

ListView 原理主要要提及RecycleBin 机制,这是ListView 能够实现大量数据都不会 OOM 的一个重要原因。它包含两个数组:mActiveViews 用于存储当前显示在屏幕上的item,mScrapViews 用于存储已经不可见的item。

ListView 自己是没有覆写 onLayout 方法的,这个方法在父类 AbsListView 中实现。第一次 layout 操作:此时ListView 中还没有任何子 View,接着自顶至底填充ListView,这个填充过程首先尝试获取一个 active view,不过此时还没有缓存任何 active view,于是只能通过Adapter 的 getView 获取view(此时convertView 是空的,只能创建);之后,调用 addViewInLayout 将这个view 添加到 ListView ,将第一屏加载完成后,这个getView 动作就会停止。

第二次Layout:如果layout执行两次的话,那么ListView 就会存在一份重复的数据了。其实第二次layout的过程中,也会去获取 active view ,不过这时候有数据了,有view了,这时候首先执行 detachAllViewsFromParent ,将ListView 中所有的item 都清除掉,detach掉,从而保证第二次 layout 过程中不会产生一份重复数据。由于这些清除掉的item 在 active view 中会有缓存,所以不会重新执行 inflate 过程。之后又重新获取active view ,获取到再 attachViewToParent 就再次添加到 ListView 中。这样经历了 detach 又 attach 过程,ListView 所有子View 就显示出来了。

在滑动的时候,不可见的view会 detach 之后回收到 mScrapViews 中。滑动展示新的item的时候,会从 mScrapViews 废弃的view中获取一个view,再调用 Adapter.getView ,并且将获取到的废弃 View 当做 convertView 传过去,接下来就是我们在 adapter 中常用的写法了。

因此,整个Listview 中总共只有那么几个固定的 item ,滑动的时候就这几个 view 在倒腾(detach 和 attach),因此不论数据量多大,都不会导致oom。

以上内容参考自郭霖的博客

30、android 5.0, 6.0, 7.0, 8.0新特性

点击看答案

5.0

  • Meterial Design
  • ART 虚拟机

6.0

  • 动态权限申请
  • 移除了 Http Client 库
  • Dozen 模式

如果用户未插接设备的电源,在屏幕关闭的情况下,让设备在一段时间内保持不活动状态,那么设备就会进入低电耗模式。在低电耗模式下,系统会尝试通过限制应用访问占用大量网络和 CPU 资源的服务来节省电量。它还会阻止应用访问网络,并延迟其作业、同步和标准闹钟。

参考官方文档

7.0

  • 多窗口支持(分屏模式)
  • JIT/AOT 交叉编译(取一个平衡,节约磁盘占用)。

8.0

  • 画中画
  • Notification 引入 channel 概念,必须设置
  • TextView 自动调整文字大小

9.0

  • 刘海屏支持
  • 多摄像头支持

Q

  • 折叠设备
  • 深色主题

31、如何导入外部数据库

点击看答案

把原来数据库的文件放在 res/raw 目录下。

我们知道Android系统下数据库应该存放在 /data/data/(packageName)/ 目录下,我们所要做的就是把已有数据库传入那个目录下。操作方法是用FileInputStream 读原数据库,再用FileOutputStream 写入到那个目录。

以上内容参考自: 如何导入外部数据库

32、Android 消息屏障

点击看答案

Handler 中的Message 可以分为三类:同步消息、异步消息 以及消息屏障(消息屏障也是一种消息,只不过target为null)。同步屏障可以通过 MessageQueue.postSyncBarrier 函数来设置(该方法是私有方法,需要反射调用,新的api 貌似提供了public 的 postSyncBarrier方法):

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
private int postSyncBarrier(long when) {
synchronized (this) {
final int token = mNextBarrierToken++;
final Message msg = Message.obtain();
msg.markInUse();
msg.when = when;
msg.arg1 = token;

Message prev = null;
Message p = mMessages;
if (when != 0) {
while (p != null && p.when <= when) {
prev = p;
p = p.next;
}
}
if (prev != null) { // invariant: p == prev.next
msg.next = p;
prev.next = msg;
} else {
msg.next = p;
mMessages = msg;
}
return token;
}
}

乍一看,这里就是往MessageQueue 中放入了一个Message ,和Handler 的post 及postDelay 一样,但是我们知道,Handler 的post 或者postDelay 时,Message 的target 字段会引用这个Handler,而设置同步屏障的时候,没有设置target字段。

异步消息和普通消息一样,Message 中 setAsynchronous(true) 操作了。而通过 MessageQueue 的next() 获取需要处理的 Message 时,有没有target 会是截然不同的处理方式:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
Message next() {
// Return here if the message loop has already quit and been disposed.
// This can happen if the application tries to restart a looper after quit
// which is not supported.

final long ptr = mPtr;
if (ptr == 0) {
return null;
}

int pendingIdleHandlerCount = -1; // -1 only during first iteration
int nextPollTimeoutMillis = 0;
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}

nativePollOnce(ptr, nextPollTimeoutMillis);

synchronized (this) {
// Try to retrieve the next message. Return if found.
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
if (msg != null && msg.target == null) {//碰到同步屏障
//一直循环,直到拿到距离表头最近的异步消息
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
// Next message is not ready. Set a timeout to wake up when it is ready.
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// Got a message.
mBlocked = false;
if (prevMsg != null) {
//将msg 从消息链表中移除
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
if (DEBUG) Log.v(TAG, "Returning message: " + msg);
msg.markInUse();
return msg;
}
} else {
// No more messages.
nextPollTimeoutMillis = -1;
}

// Process the quit message now that all pending messages have been handled.
if (mQuitting) {
dispose();
return null;
}

// If first time idle, then get the number of idlers to run.
// Idle handles only run if the queue is empty or if the first message
// in the queue (possibly a barrier) is due to be handled in the future.
//如果能执行到这里,说明当前没有需要处理的 msg (queue 为空,或者 msg 的时间还没到)
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
//获取当前IdleHandler的数量
pendingIdleHandlerCount = mIdleHandlers.size();
}
//没有需要处理的idleHandler,退出
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked = true;
continue;
}

if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
//用将所有的idleHandler存入mPendingIdleHandlers
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
}

// Run the idle handlers.
// We only ever reach this code block during the first iteration.
//迭代处理
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler

boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}
//根据返回值,选择是否remove这个idleHandler
if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}

// Reset the idle handler count to 0 so we do not run them again.
pendingIdleHandlerCount = 0;

// While calling an idle handler, a new message could have been delivered
// so go back and look again for a pending message without waiting.
nextPollTimeoutMillis = 0;
}
}

如果碰到没有 target 的 msg ,则会一直遍历是否有异步的消息,如果有,则马上处理,可以说设置了同步屏障后,Handler 只会处理异步消息(在达到目标后,要求手动调用 MessageQueue.removeSyncBarrier 来移除屏障)。

当messageQueue 中没有msg 或者最早的一个 msg 都要在一段时间之后执行,那么如果直接让线程空转有点浪费,在这个时候,系统会去调用这个IdleHandler 接口回调(如果有的话),如果上述代码返回false,说明只需要执行一次,在执行完了之后,将会被remove掉;如果返回true,则认为会执行多次。

使用场景:Android系统中存在Vsync 消息,主要负责 16ms 更新一次屏幕展示,如果同步消息在16ms内没执行完成,就会出现掉帧,用户感觉卡顿。假如在 Vsync 消息加入 MessageQueue 时前面还有 10个同步消息,每个消息执行10ms,那么总共也需要100ms ,这段时间会丢掉很多帧,为了解决这种排队等候,可以使用同步屏障+异步消息。如 ViewRootImpl 的 scheduleTraversals 方法就是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
if (!mUnbufferedInputDispatch) {
scheduleConsumeBatchedInput();
}
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}

以上内容参考自Handler之同步屏障机制Android 源码分析 - Handler的同步屏障机制、同步屏障的使用示例可以参考Android中异步消息和同步屏障

33、引申-IdelHandler

点击看答案

通过以下代码可以添加IdleHandler (注意是Looper.myQueue,而不是Looper.myLooper:

1
2
3
4
5
6
7
8
//getMainLooper().myQueue()或者Looper.myQueue()
Looper.myQueue().addIdleHandler(new IdleHandler() {
@Override
public boolean queueIdle() {
//你要处理的事情
return false;
}
});

结合消息屏障中列出的代码可知,如果在 queueIdle 方法中返回false,则在方法执行完成之后,这个 IdleHandler 将会被移除,即只执行一次;如果return true,则会多次执行。

IdelHandler 的常用场景有:1、延迟执行:当Activity 启动时,需要延时执行一些操作,以免启动过慢,我们通常使用postDelay的方式执行,但是这个delay的时间不太好把握,这时候用 IdelHandler 会更优雅。 2、批量任务,只关心最终结果,例如开发im应用,通常情况下每收到一个消息都会刷新一下ui,短时间内收到多个消息,就会刷新多次界面,容易造成卡顿,影响性能,这时候可以通过子线程监听im消息,通过IdelHandler 刷新ui是比较理想的。

以上内容参考自张小凡凡

34、卡顿之-BlockCanary 原理

点击看答案

卡顿可以使用 BlockCanary 去监测,它需要你自己指定超时的阈值,超过这个阈值就展示出来。我们只知道这个功能,但是它的原理是啥呢?我们可以首先看下 Looper 的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* Initialize the current thread as a looper, marking it as an
* application's main looper. The main looper for your application
* is created by the Android environment, so you should never need
* to call this function yourself. See also: {@link #prepare()}
*/
public static void prepareMainLooper() {
prepare(false);
synchronized (Looper.class) {
if (sMainLooper != null) {
throw new IllegalStateException("The main Looper has already been prepared.");
}
sMainLooper = myLooper();
}
}

/**
* Returns the application's main looper, which lives in the main thread of the application.
*/
public static Looper getMainLooper() {
synchronized (Looper.class) {
return sMainLooper;
}
}

我们整个进程就只有一个主线程,主线程只有一个 mainLooper ,所以不管多少Handler 与主线程相关,最终都会让这个 mainLooper 来处理,我们再来看下 mainLooper 处理事务的逻辑:

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
public static void loop() {

...

for (;;) {
Message msg = queue.next(); // might block

// 获取printer
final Printer logging = me.mLogging;
if (logging != null) {
// 在执行特定message 之前打印日志
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
// Make sure the observer won't change while processing a transaction.

//处理事务
...

if (logging != null) {
//事务处理完成后,再次打印
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}

msg.recycleUnchecked();
}
}

这个 Printer 类型的 mLogging,在每个 Message 处理前后都调用了一遍,printer 流程卡住了,不就是主线程卡住了吗?而我们只需要执行以下代码:

Looper.getMainLooper().setMessageLogging();

就可以设置自己自定义的 Printer ,这样当卡顿发生时,就能感知了。

以上内容参考自 BlockCanary作者的博客

35、Android 跨进程通信之 Binder

点击看答案

Linux 内核提供了丰富的进程间通信机制,如 管道(pipe)、信号(signal)、消息队列(Message)、共享内存(Share Memory) 以及 Socket 等。——摘自《Android系统源代码分析》

Android 中使用 Binder 进行多进程间通信只需要一次数据拷贝,效率上仅次于共享内存。Binder IPC 机制通过 mmap() 内存映射实现,内存映射简单讲就是将用户空间的一块内存区域映射到内核空间。Android 中Binder 进程间通信示意图如下:

Binder进程间通信

Binder 通信中的代理模式

我们已经解释清楚 Client、Server 借助 Binder 驱动完成跨进程通信的实现了,但是还有问题让我们困惑:A进程想要B进程中某个对象(object) 是如何实现的呢?毕竟分数不同的进程,内存地址映射规则也不一样,A进程没法直接使用B进程中的object。

其实,数据流经过Binder驱动都会左一层转换。当A进程想要获取B进程中的object 时,驱动并不会真的把 object 返回给 A,而是返回一个与 object 看起来一样的代理对象 objctProxy,这个 objctProxy 有object 的所有方法,但是这些方法没有 objct 方法中的那些能力,这些方法的主要工作就是将请求参数交给 Binder 驱动,而对于A进程来讲,就和直接调用 object 对象一样一样的。

当Binder 驱动收到 A 进程的消息后,发现是 objctProxy ,接着查询自己维护的表单,发现它是B进程 object 的代理对象,于是就通知B进程调用 object 的指定方法,并要求将结果返回自己。之后Binder 驱动将结果转发给A进程,一次通信就完成了。具体通信过程如下图所示:

通过代理模式通信

多进程通信方式选择

如果想进程间通信,但是无需多线程,可以使用 Messenger;如果需要进程间通信,并且还需要再服务中处理多线程,那就使用AIDL(其实Socket也是能实现的)。

顺带一提:App实现多进程有很多弊端,比如:静态和单例会失效(不是同一规则的内存映射)、sharedPreference 会不可靠 等

以上内容参考自Android进程间通信

36、事件分发

点击看答案

参考以前的事件分发专题 即可。

37、所谓的Android 开发高手课

点击看答案

Bitmap

bitmap 是烧内存大户,3.0~7.0中会将bitmap对象和像素数据统一放到Java堆中,不过还是会引起大量gc甚至导致oom。将Bitmap 内存放到Native 中可以做到和对象一起快速释放,Android 8.0 中提供 NativeAllocationRegistry 帮助将bitmap 放到 native 内存,同时还能满足与对象一起回收。

ANR

首先看主线程的堆栈,查看是否因为锁等待导致。接着看 ANR 日志中的 iowait、CPU、GC、systemt server 等信息,进一步确定是否是 io 问题,或者是 CPU 竞争问题,还是由于大量 GC 导致卡死。从 Logcat 中能够看到当时系统的一些行为,比如出现 ANR 时,会有 “am_anr”,App被kill 时,会有 “am_kill”。

Serializable

  • 整个序列化过程使用了大量的反射和临时变量,而且在序列化对象的时候,不仅会序列化当前对象本身,还需要递归序列化对象引用的其他对象
  • 因为存在大量反射和 GC 的影响,序列化的性能会比较差。另外一方面因为序列化文件需要包含的信息非常多,导致它的大小比 Class 文件本身还要大很多,这样又会导致 I/O 读写上的性能问题
  • Parcel 序列化和 Java 的 Serializable 序列化差别还是比较大的,Parcelable 只会在内存中进行序列化操作,并不会将数据存储到磁盘里。

参考自别人的博客

38、Android 中大图加载

点击看答案

一般为了尽可能避免OOM,图片加载都会按照如下做法:

  • 如果仅仅只需要读取大图的尺寸和类型,那我们没必要将其加载到内存,在解码的时候,指定 BitmapFactory.Options 中的inJustDecodeBounds 属性设置为true即可,这样避免为bitmap 分配内存,但是能读到图片的尺寸和类型。根据图片的大小以及ImageView 的大小,我们可以配置 BitmapFactory.Options.inSampleSize 来确定加载到内存中图片的大小。
  • 对于图片显示,根据需要显示图片空间的大小对图片进行压缩显示(我们是提交给后台的链接中拼接了所需要的尺寸)
  • 如果图片非常多,则会使用 LruCache 等缓存机制,将所有图片占据的内容维持在一个范围。详情可以参考郭霖的博客

但是,还有一种情况,如果单个图片非常巨大,并且还不允许压缩(如清明上河图、世界地图等),那么我们可以使用 BitmapRegionDecoder 来实现。

BitmapRegionDecoder 的原理是:给定一个矩形区域(Rect),然后通过 Bitmap bitmap = mDecoder.decodeRegion(mRect,opthions) 方法来获取这个区域的 bitmap 展示。因此我们大图展示过程中,都是一次次分割实现的。

以上内容参考自鸿洋官方文档

39、关于Android绘制

点击看答案

原理

60帧的画面让人感觉不到画面的更新,因此Android系统中基本上是 60帧/s的刷新频率,也就是每16ms 发出一次 VSYNC信号触发对UI的绘制。当然,我们要明白,这个 16ms 不只是全用来绘制界面,而是会包括layout、measure

整体的过程就是,cpu执行计算任务,即 layout、measure后将ui计算成多维图形(多边形、纹理),再经过OpenGL处理,之后交给GPU 进行栅格化后显示在屏幕上。 因此,16ms的时间主要被两件事情占用,第一件:将UI对象转换为一系列多边形和纹理;第二件:CPU传递处理数据到GPU进行栅格化

Frame Buffer中的数据是怎么来的

GPU 从Frame Buffer 中获取数据绘制,但其除了 Frame Buffer 外,还有缓冲的Back Buffer ,GPU 也会定时地切换这两个 Buffer 的角色(可能其中一个为Frame Buffer,另一个就为 Back Buffer),由于16ms 发出一次 VSYNC 信号,因此这个切换也是 16ms。

在系统将Back Buffer 交给应用填充数据时,实际过程是将 Back Buffer 锁定,讲一个指向它的引用交给你的应用,这个引用就是Canvas对象,View的onDraw 方法中接收到的Canvas就是它。我们知道,父view在onDraw的时候,会一直调用子View的onDraw方法,这个Canvas 就会一直传递下去给每一个View。当所有的View 都通过Canvas 绘制完成后,才算完成了一帧的绘制。

丢帧是怎么发生的

上面说GPU 会定期交换 Back Buffer 和 Frame Buffer ,但是有一个例外情况,当你的应用正在往 Back Buffer 中填充数据时,系统会将 Back Buffer 锁定,如果到了 GPU 交换两个Buffer 的时间点,你的应用还在往Back Buffer 中填充数据,GPU 会发现 Back Buffer 被锁定了,它会放弃这次交换(即发生Jank了),导致的结果就是手机屏幕仍然显示原来的图像,即用户在32ms内看到的是同一帧。

开发者如何避免

可以从两个方面考虑:

  • CPU产生的问题:不必要的布局和失效
  • GPU产生的问题:过度绘制(overdraw)

1、避免cpu 计算任务过重。1、减少在onDraw 方法中创建对象,尤其是复杂对象。 2、减少视图层次,尽量使用ConstrainLayout 等代替多层嵌套
2、避免cpu、GPU 任务过重,减少不必要的View的invalidate 调用(不invalidate可以让gpu最大限度使用缓存)
3、减少过度绘制。1、clipRect帮助识别可见的区域。2、去除View 中不必要的background,因为许多background 并不会显示在最终的屏幕上。比如ImageView,假如它现实的图片填满了空间,你就没有必要给它设置一个背景色。

以上内容参考自wei_leimilterjianshu

40、屏幕尺寸变化适配有什么手段

点击看答案
  • 使用 match_parent、wrap_content 等方式藐视控件大小
  • 使用宽度限定符,如 layout-w600dp
  • 屏幕方向限定符,如 layout-land、layout_port 来适配横竖屏
  • 使用Fragment,将界面组件化
  • 使用.9图片

41、像素密度

点击看答案

为了保证在密度不同的屏幕上看起来尺寸相同,必须使用密度无关的像素(density-independent pixels,简称 dp 或者 dip) 作为单位。1dp 是以中密度屏幕(像素密度(dpi,每平方英尺上的像素个数):160dpi)作为基准密度,在基准上1dp = 1px。不过在定义文本时,应该使用可缩放像素(scalable pixels,简称: sp)作为单位。默认情况下,dp 与sp大小相同,但是当用户在设置中调整文本大小时,sp就会变化了。还有,要注意的是,我们平时说的屏幕的尺寸是指的屏幕对角线长度。

以上内容参考自官方文档

42、彻底理解android 应用无响应机制

点击看答案

首先总结下ANR发生的情况以及阈值:

Anr发生的阈值

ANR的原理基本上都是执行某项操作task之前,通过Handler 发送一个延时 Message,如果在延时时间到达之前 task 执行完成,则通过handler removeMessage 将Message 移除,否则,在延时时间到达之后还未能完成,Handler 便会处理这个message,从而抛出ANR。尤其需要注意的有以下几点:

  • 通过静态注册的广播(动态广播不用考虑)在完成前会检查 SharedPreference 是否已经完成同步到磁盘,如果没有,要等其完成才能告知系统已经完成。
  • Provider 只有在进程首次启动的时候才会检测ANR,当provider 进程已经启动,再次请求provider 不会触发超时。
  • Activity 退出也会检查 sp 是否已经同步到磁盘,未完成的话,也会等待。

回答有哪些路径会导致ANR

从handler 发送了 message 到 removeMessage 之前的任何一个环节都可能出现ANR,比如:service 的回调方法慢,比如主线程的消息队列存在耗时消息让service 的回调迟迟得不到执行,可能是sp操作缓慢,可以是system_server 进程的binder 线程繁忙,而导致 removeMessage 没来得及执行,也有可能是广播在等待sp操作,等等。

ANR 避免

  • 主线程尽量只做UI相关操作,避免耗时操作,如过度绘制、IO操作
  • 避免主线程与工作线程发生锁竞争
  • 谨慎使用SharedPreference

以上内容参考自gityuan的博客gityuan博客

43、为什么要使用Binder 机制进程间通信

点击看答案

Linux现有的IPC 方式

  • 管道:信息复制2次。在创建时分配一个page大小的内存,缓存区大小比较有限;

  • 消息队列:信息复制2次。额外的CPU消耗;不合适频繁或信息量大的通信;

  • 共享内存:无须复制,共享缓冲区直接付附加到进程虚拟地址空间,速度快;但进程间的同步问题操作系统无法实现,必须各进程利用同步工具解决;

  • 套接字(Socket):信息复制2次。作为更通用的接口,传输效率低,主要用于同机器或跨网络的通信;

  • 信号量:常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

  • 信号: 不适用于信息交换,更适用于进程中断控制,比如非法内存访问,杀死某个进程等;

为什么采用Binder(从5个维度)

总结下:从性能的角度、从稳定性的角度、从安全的角度、从语言层面的角度、从公司战略的角度

  • 从性能的角度:从拷贝次数看,仅次于共享内存。但是共享内存会遇到进程同步,太复杂。
  • 从稳定性的角度:Binder 基于 C/S 架构,清晰明了,Client 端与Server 端相对独立。
  • 从安全的角度:传统的Linux IPC 只能由用户在数据包里填入UID/PID,接收方无法获得对方进程可靠的 UID/PID ,从而无法鉴别对方身份。而Android 为每个安装好的应用都分配了自己的UID。此外,C/S架构有利于Server端根据UID鉴别访问权限。
  • 语言层面:Binder 这种面向对象的思想与Android 开始的开发语言 Java 高度契合。
  • 公司战略: 公司战略层面就不多说了

另外,Linux没有采用Binder 不是他们没想到,而是Binder 更适合Android这种手持设备而已。

以上内容参考自gityuan的csdn

44、Binder 机制

点击看答案

而一般的进程间通信方式(共享内存除外),需要Client端进程空间拷贝到内核空间,再由内核空间拷贝到Server进程空间,会发生两次拷贝。

Binder 进程间的高效率通信的秘诀在于 binder_mmap() ,如下图示意:

Binder的mmap操作

虚拟进程地址空间(vm_area_struct)和虚拟内核地址空间(vm_struct)都映射到同一块物理内存空间。当Client端与Server端发送数据时,Client(作为数据发送端)先从自己的进程空间把IPC通信数据copy_from_user拷贝到内核空间,而Server端(作为数据接收端)与内核共享数据,不再需要拷贝数据,而是通过内存地址空间的偏移量,即可获悉内存地址,整个过程只发生一次内存拷贝。

进程和内核虚拟地址映射到同一个物理内存的操作是发生在数据接收端,而数据发送端还是需要将用户态的数据复制到内核态。

下图展示了通过Binder 进行进程间通信:

Binder进程间通信示意

我个人理解,client 与server 双方都有进行 mmap 映射操作,但是在用户空间获取到的映射空间只能读,不能写。这样,双方发送数据时都只需要执行 copy_from_user 即可,在接收端通过地址偏移就能获取到数据

universus的博客(据说这篇博客是介绍binder的神级存在)能看出来确实两端都有映射。

以上内容参考自gityuan的博客

45、动画

点击看答案

帧动画、View 动画(补间动画)、属性动画

  • 帧动画就一帧帧播放
  • View动画:指定开始状态和结束状态,中间的view会自动被补齐,主要支持 平移、缩放、透明度、旋转 四种基本效果。主要应用场景: view的动画 以及 Activity、Fragment 的切换动画。注意:View 动画执行到某个位置时,它的动作响应(比如点击)还停留在原来位置的,只有点击原来位置才有效,因为它不是真正改变View的属性。
  • 属性动画:真正的视图移动,点击移动后的视图会有效果。

以上内容参考自会飞的鱼

46、关于Android绘制

点击看答案

原理

60帧的画面让人感觉不到画面的更新,因此Android系统中基本上是 60帧/s的刷新频率,也就是每16ms 发出一次 VSYNC信号触发对UI的绘制。当然,我们要明白,这个 16ms 不只是全用来绘制界面,而是会包括layout、measure

整体的过程就是,cpu执行计算任务,即 layout、measure后将ui计算成多维图形(多边形、纹理),再经过OpenGL处理,之后交给GPU 进行栅格化后显示在屏幕上。 因此,16ms的时间主要被两件事情占用,第一件:将UI对象转换为一系列多边形和纹理;第二件:CPU传递处理数据到GPU进行栅格化

Frame Buffer中的数据是怎么来的

GPU 从Frame Buffer 中获取数据绘制,但其除了 Frame Buffer 外,还有缓冲的Back Buffer ,GPU 也会定时地切换这两个 Buffer 的角色(可能其中一个为Frame Buffer,另一个就为 Back Buffer),由于16ms 发出一次 VSYNC 信号,因此这个切换也是 16ms。

在系统将Back Buffer 交给应用填充数据时,实际过程是将 Back Buffer 锁定,讲一个指向它的引用交给你的应用,这个引用就是Canvas对象,View的onDraw 方法中接收到的Canvas就是它。我们知道,父view在onDraw的时候,会一直调用子View的onDraw方法,这个Canvas 就会一直传递下去给每一个View。当所有的View 都通过Canvas 绘制完成后,才算完成了一帧的绘制。

丢帧是怎么发生的

上面说GPU 会定期交换 Back Buffer 和 Frame Buffer ,但是有一个例外情况,当你的应用正在往 Back Buffer 中填充数据时,系统会将 Back Buffer 锁定,如果到了 GPU 交换两个Buffer 的时间点,你的应用还在往Back Buffer 中填充数据,GPU 会发现 Back Buffer 被锁定了,它会放弃这次交换(即发生Jank了),导致的结果就是手机屏幕仍然显示原来的图像,即用户在32ms内看到的是同一帧。

开发者如何避免

可以从两个方面考虑:

  • CPU产生的问题:不必要的布局和失效
  • GPU产生的问题:过度绘制(overdraw)

1、避免cpu 计算任务过重。1、减少在onDraw 方法中创建对象,尤其是复杂对象。 2、减少视图层次,尽量使用ConstrainLayout 等代替多层嵌套
2、避免cpu、GPU 任务过重,减少不必要的View的invalidate 调用(不invalidate可以让gpu最大限度使用缓存)
3、减少过度绘制。1、clipRect帮助识别可见的区域。2、去除View 中不必要的background,因为许多background 并不会显示在最终的屏幕上。比如ImageView,假如它现实的图片填满了空间,你就没有必要给它设置一个背景色。

以上内容参考自wei_leimilterjianshu

47、Kotlin 优势

点击看答案

按照官网上的说法:

  • 简洁。

判空、getter、setter 方法、命名传参(动态改变参数)无需重载,可能结合anko 之类的更加简单

  • 安全

减少空指针等错误、类型判断过后,自动类型转换

  • 兼容java

可以混编

48、多进程 webview

点击看答案

想着app只有一个 Cookies 的 db 文件,估摸着跨进程的 webview 能共享这个cookie数据。今天试了试,结果发现俩问题:

  • 加上跨进程后,在一加6上(Android 10 系统)运行崩了,但是在魅族15上(Android 7.1.1系统)运行良好
  • 主进程webview 的cookies 正常,但是新进程的webview 加载 cookies 缺失。

很奇怪的是,通过 adb shell ,run-as com.esun.ui ,获取app_webview 中的 Cookies 文件用sqlite3 打开,里面的 cookies 一条条又是正常的!初步断定是同步的问题,因为只有一个db文件,不可能不一样。

至于,一加手机不能正常使用,是因为Android P及以上的版本不支持从多个进程使用具有相同数据目录的Webview,官方的解决方法就是给不同进程的Webview 设置不同的数据目录(在Application中):

1
2
3
4
5
6
7
8
9
@RequiresApi(api = Build.VERSION_CODES.P)
public static void initWebViewDataDirectory(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
String processName = getProcessName(context);
if (!context.getPackageName().equals(processName)) {//判断是否是默认进程名称
WebView.setDataDirectorySuffix(processName);
}
}
}

以上内容参考自liudave简书上的博客

49、无需root,获取应用在/data/data{packageName}/里面的数据

点击看答案

在debug 包情况下,我们可以在未经授权的情况下获取 /data/data{packageName}/ 目录下的数据,比如:Cookies 的db 文件(虽然没有db后缀,但是就是个db文件)、SharedPreference 的xml 文件等。具体步骤:

  1. 执行 adb shell
  2. run-as {包名} ,例如: run-as com.example.haha
  3. 这时候 ls 命令能看到我们 app 在/data/data/ 目录下的所有文件了

如果需要将 Cookies 文件拷贝到电脑桌面的话,我们需要先将其拷贝到sd卡上:

  1. 首先,拷贝到sd卡上: cp app_webview/Cookies /sdcard/
  2. 其次,退出 shell 模式,执行: exit (多次执行,直到回到初始状态)
  3. 最后,使用adb pull 命令将文件拉出来

如果需要查看这个Cookies 的db 文件,可以使用以下指令即可查看:

sqlite3 /home/sample/Desktop/Cookies
.dump cookies

50、签名

点击看答案

APK Signature Scheme v1

解压一个签名后的 APK ,在 META-INF 目录下会有三个文件: MANIFEST.MF、CERT.SF、CERT.RSA。它们就是v1签名的关键。

其中,MANIFEST.MF存储了APK中的每个文件的文件名和摘要,类似如下形式(上面是文件名,下面是文件的 SHA256 消息摘要之后进行 Base64编码):

Name: AndroidManifest.xml
SHA-256-Digest: bWeqbdj+sLYqFQPe/j3kjv7hGZZYFm+YheK2AwGnW90=

CERT.SF 称为二次摘要文件。它的格式和 MANIFEST.MF 的一样,也是上面name,下面摘要,只不过摘要是对 MANIFEST.MF 中的摘要条目做摘要(对MANIFEST.MF摘要条目进行SHA-256摘要再base64处理,值得注意的是,CERT.SF中存储了 MANIFEST.MF 整个文件的 摘要值):

Name: AndroidManifest.xml
SHA-256-Digest: bQWn4Jvp6bjlQUQQ8cr1NO9nl9hrMXMTbVeXGULZwSI=

最后,CERT.RSA 文件与 CERT.SF 文件是相互对应的,二者的名字一样,它里面主要存储了证书的公钥、过期日期、发行人、加密算法、CERT.CF文件的签名(使用私钥对CERT.CF文件的签名)等。

从以上信息我们知道,使用不同的keystore进行签名时,除了 CERT.RSA 文件外,其余两个文件都是一样的。也就是说前两者主要保证各个文件的完整性,而 CERT.RSA 用来保证apk的来源即完整性。

v1 签名校验过程

  1. 检查apk中的文件对应的摘要值是否与 MANIFEST.MF 记录的一致
  2. 使用 CERT.RSA 文件检验签名文件 CERT.SF 文件是否被修改过
  3. 使用 CERT.SF 校验 MANIFEST.MF 文件是否被修改过

为什么这个顺序呢?假设一下,如果你改了apk的文件,那么在安装apk文件时,第一步通过 MANIFEST.MF 校验不通过;假如你改了文件重新计算摘要值,更新了 MANIFEST.MF 文件,那么必定与 CERT.SF 文件中计算的值不一样。最后的保障是

v1的缺点

  • META_INF文件不在校验范围内,很容易绕过
  • 单个文件的完整性校验在安装的时候比较耗时

APK Signature Scheme v2

v2签名不是针对单个文件,而是将apk分成 1M 大小的块,对每个块计算摘要(由于块摘要可以并行处理,因此可以提高校验速度),之后对所有摘要进行摘要得到顶级摘要,之后利用私钥对顶级摘要签名得到数字证书(即得到数字证书)。如下图所示:

v2摘要生成图

为了保护 APK 内容,整个 APK(ZIP文件格式)被分为以下 4 个区块:

  • ZIP 条目的内容(从偏移量 0 处开始一直到“APK 签名分块”的起始位置)
  • APK 签名分块
  • ZIP 中央目录
  • ZIP 中央目录结尾

可以看到,Android签名存放区域是zip文件的中央目录(central Directory)之前。v2对整个apk签名,因此如果需要对齐(zipalign)的话,必须先对齐后签名。

以上内容参考自csdn上的博客简书上的博客jb51

51、浅谈TouchDelegate的坑与用法

点击看答案

用于扩大点击区域

以上内容参考自简书

52、BitMap 内存管理

点击看答案

Android 2.3.3即更低版本

无需使用bitmap 的时候,调用其 recycle() 方法

Android 3.0 及更高版本

引入了 BitmapFactory.Options.inBitmap 字段,如果设置了此字段,那么采用 Options 对象的解码方法会在加载内容时尝试重复使用现有位图。不过,使用会有一些限制,尤其是4.4以前,仅支持大小相同的位图的复用。

原理:当Bitmap 从 LruCache 删除时,对其的软引用会防止在HashSet 中,以供之后通过 inBitmap 重复使用。

以上内容参考自官方文档

53、epoll

点击看答案

epoll机制提供了Linux平台上最高效的I/O复用机制,它的主要作用是I/O复用,即在一个地方等待多个文件句柄的I/O事件

epoll的效率为什么比 select/poll高呢?有以下几个原因:

1、每次调用select 时,需要把感兴趣的事件都复制到内核中,二epoll 只在epll_ctl 进行加入操作的时候才复制一次
2、epoll内部用于保存事件的数据结构使用的是红黑树,查找速度很快,二select采用数组保存信息,不但一次能等待的句柄有限,并且在事件较多时查找起来速度很慢

以上内容参考自《深入理解Android:卷II》

54、杂

点击看答案

系统有个 framework-res.apk ,这个APK 除了包含资源之外,还包含一些Activity(如关机对话框) ,这些Activity 实际上运行在 system_server 进程中,从这个角度看,system_server 是一个特殊的应用进程

LoadedApk 用于保存一些和 APK 相关的信息(如资源文件位置、JNI 库位置等)

ActivityThread 中包含一个mLooper 成员,代表一个消息循环。mServices 用于保存 Service ,Activityes 用于保存 ActivityClientRecord,mAllApplications 用于保存 Appkication (注意,我们获取 ApplicationContext 的时候,首先从 LoadApk 中获取,没成功再从 ActivityThread 中获取)。

55、BroadCastReceiver

点击看答案

我们知道,在 BroadCastReceiver 的onReceive 中不能执行耗时操作,但是如果我们有这个需求的时候,怎么办呢?其实,如果这个处理比较耗时,可以采用异步的方式处理: 即先调用 BroadCastReceiver 的 goAsync 方法得到一个 PendingResult 对象,然后将该对象放到工作线程中处理,可以参考的代码如下:

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
public void onReceive(final Context context, final Intent intent) {
final PendingResult result = goAsync();
wl.acquire();
AsyncHandler.post(new Runnable() {
@Override
public void run() {
handleIntent(context, intent);//耗时操作
result.finish();
}
});
}


public final class AsyncHandler {
private static final HandlerThread sHandlerThread = new HandlerThread("AsyncHandler");
private static final Handler sHandler;

static {
sHandlerThread.start();
sHandler = new Handler(sHandlerThread.getLooper());
}

public static void post(Runnable r) {
sHandler.post(r);
}

private AsyncHandler() {}
}

以上内容参考自 《深入理解Android:卷II》第268页、csdn的博客简书的博客

Sticky 的广播,一旦有接收者注册,系统马上将该广播传递给它们。

动态注册的非order广播,在 sendBroadcast 的时候,可以直接发送,不需要等待上一个Receiver 接收处理完成后才发送下一个。而静态注册的广播,则必须处理完一个接收者才能处理下一个接收者。这是因为需要避免惊群效应,动态广播的接收者的进程是肯定存在的(如果不存在肯定没法注册),而静态注册的广播接收者不能保证它已经和进程绑定了(进程可能还没启动),假如发送广播时,这些接收者进程都不存在,那么一下子就创建了多个进程,系统压力陡增。每次处理一个的recevier的坏处在于延时较长。

以上内容参考自《深入理解Android:卷II》

56、证书校验

点击看答案

主要是为了更进一步的安全,防止通过 fiddler 抓包

crt格式的证书保存在assets文件夹中,同时,也可以从后台获取证书,保存在sp文件中,从后台获取证书时,需要将tag(标记客户端当前已有证书的版本,由于本地默认在assets文件夹中有证书,所以默认也有个tag)传过去,如果tag为空,则后台肯定返回证书,否则,判断客户端不是最新证书的情况会返回最新证书。马上判断证书中是否包含请求证书这个接口的host,包括才算成功。如果asset中的证书未失效,那么下载的证书就下次使用;反之,得后台的证书下载下来马上就使用。通过 CertificateFactory 将crt文件格式的证书转换成 x.509 格式的对象,之后,对比本地证书的公钥和服务端返回的公钥的值是否一致来决定证书是否校验通过。

有个细节,获取证书的这个接口请求是通过ip请求的(首先通过 114 或者 119 获取host 的ip),然后将真正的host设置在 header 中,在从后台下载到证书后,验证证书里面是否包含有这个接口的host,包含了才算通过。

57、RecyclerView与ListView的区别

点击看答案

1、局部刷新。RecyclerView 可以局部刷新,而ListView不可以
2、ListView的ViewHolder 需要自己定义,并且不是强制要求的;而RecyclerView 是已经封装好了,是强制的
3、ListView 的Adapter 继承的是 BaseAdapter;RecyclerView 的Adapter 继承的是 RecyclerView.Adapter
4、ListView可以设置分割线;RecyclerView 只能自己些DecodeItem
5、ListView 可以针对Item直接添加点击事件,RecyclerView只能自己写回调
6、ListView的显示方式没有RecyclerView灵活,后者可以使设置成瀑布流、竖直的、横向的,网格的
7、缓存机制不同,缓存层级

参考自简书简书

58、BitmapFactory.Options 用于 Bitmap 内存优化

点击看答案

Options 的成员变量挺多的,类似 inBitmap、inMutable、inJustDecodeBounds、inSampleSize、outWidth、outHeight、outConfig… ,看规律可以发现有 in 和 out 两类命名风格,in 开头可以理解为设置参数,out开头可以理解为获取某些参数。

通过正确使用上述参数,可以很好操作 Bitmap ,减少资源滥用,减少Bitmap的内存占用。

通过 inJustDecodeBounds 获取图片信息

如果仅仅需要获取图片信息而不要实际使用 Bitmap ,可以为 Options 设置 inJustDecodeBounds 为 true,这样 bitmap 返回的是 null,但却可以获取图片的宽高等信息。

通过 inSampleSize 降低采样

很多时候,我们所需要的图片比原图小,这时候可以设置 inSampleSize 来减小图片宽高,当彩艳率(inSampleSize) > 1 时,长和宽对应变为原来的 1 / inSampleSize ,对应的 bitmap 也缩小为原来的 1/(inSampleSize^2)。同时强调解码器使用(我个人的理解,此处指的是inSampleSize)基于2的幂的最终值,任何其他值都将被舍入到最接近的2的幂次,示例代码如下所示:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class TestBitmapSize extends AppCompatActivity {
private static final String TAG = "TestBitmapSize";

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main2);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.test_200_200);
Log.i(TAG, "onCreate: originBitmap " + bitmap.getByteCount());
Bitmap sampledBitmap = decodeSampledBitmapFromResource(getResources(), R.drawable.test_200_200, 50, 50);
Log.i(TAG, "onCreate: sampledBitmap " + sampledBitmap.getByteCount());
}

public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) {
// 检查bitmap的大小
final BitmapFactory.Options options = new BitmapFactory.Options();
// 设置为true,BitmapFactory会解析图片的原始宽高信息,并不会加载图片
options.inJustDecodeBounds = true;

BitmapFactory.decodeResource(res, resId, options);

// 计算采样率
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);

// 设置为false,加载bitmap
options.inJustDecodeBounds = false;

return BitmapFactory.decodeResource(res, resId, options);
}

private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
int width = options.outWidth;
int height = options.outHeight;
Log.i(TAG, "calculateInSampleSize: out width and height is " + width + " height " + height);
int inSampleWidth = 1;
if (height > reqHeight || width > reqWidth) {
int halfHeight = height / 2;
int halfWidth = width / 2;

// 采样率设置为2的指数
while ((halfHeight / inSampleWidth) >= reqHeight && (halfWidth / inSampleWidth) >= reqWidth) {
inSampleWidth *= 2;
}
}
return inSampleWidth;
}
}

通过 inBitmap 重用 Bitmap 内存复用

当需要多次重复创建 Bitmap 的时候,可以考虑使用 inBitmap 实现 Bitmap 的重用。

Bitmap 的复用的前提是,前一个 Bitmap 是可变的 mutable,即我们在设置 BitmapFactory 中 Options 的时候,inMutable 参数设置为true,之后,把前一个 bitmap 设置给 Options的 inBitmap,在后续的 Bitmap 创建时,如果也是使用同一个Options 的话,可以做到复用了。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 测试bitmap复用
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.bigbackground, options);
// 对象内存地址;
Log.i(TAG, "bitmap = " + bitmap);
Log.i(TAG, "bitmap:ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount());

options.inBitmap = bitmap;

// 返回的bitmap还是可变的,这个属性可以不设置
// options.inMutable = true;
Bitmap bitmapReuse = BitmapFactory.decodeResource(getResources(), R.drawable.smallbackground, options);
Log.i(TAG, "onCreate: isMutable" + bitmapReuse.isMutable());
// 复用对象的内存地址;
Log.i(TAG, "bitmapReuse = " + bitmapReuse);
Log.i(TAG, "bitmapReuse:ByteCount = " + bitmapReuse.getByteCount() + ":::bitmapReuse:AllocationByteCount = " + bitmapReuse.getAllocationByteCount());

以上内容参考自CSDN上的博客

59、ListView 和 RecyclerView

点击看答案

如何选择

如果我们需要频繁地刷新列表数据且列表数据需要添加动画或者列表数据相差不太大的话,还是使用 RecyclerView,对于前后数据相差不大的情况,还可以使用 DiffUtil 来进行差分,这样,就能实现定向刷新和局部刷新。

除此以外,还是建议使用ListView ,不仅仅分割线之类的方便,它还有很多便捷的Adapter,CursorAdapter、ArrayAdater 等。

60、RecyclerView的缓存机制

点击看答案

RecyclerView 缓存ViewHolder 有4个等级,优先级从高到低有4个层次:

  1. mAttachedScrap: 缓存屏幕中可见范围的ViewHolder
  2. mCachedViews: 缓存滑动时即将与RecyclerView 分离的 ViewHolder,存有postion信息,如果需要复用,直接可以拿过去用,不需要改变数据。默认最多2个
  3. ViewCacheExtention: 用户自定义的扩展缓存,需要用户自己管理View的创建和缓存。
  4. RecycledViewPool:缓存池。在 mCacheViews 中缓存已满时,就会将旧的ViewHolder 放到RecyclderViewPool,如果 RecyclderViewPool 满了,就不会再缓存。从这里取出的缓存需要bindView 才能使用(本质上是一个SparseArray,其中key是ViewType(int类型),value存放的是 ArrayList< ViewHolder>,默认每个ArrayList中最多存放5个ViewHolder)。

61、到底在哪个阶段存储数据

点击看答案

官方文档说在Android 3.0 以后,应用只有等到 onStop 之后才能被杀死,官方还建议,应当在onPause方法中存储那些持久性的数据,比如用户输入等,onSaveInstanceState 将在Activity 转入后台状态之前被调用,能让我们存储一些Activity 的动态的状态值(比如组件的宽高之类的)到Bundle对象中,onSaveInstanceState 方法在onStop 方法之前调用,但是它跟onPause 回调没啥关系,但是还有一点要注意的是:onSaveInstanceState 并不是Activity的生命周期中的回调方法之一,所以,在Activity 被杀死的时候,不能保证百分百执行的!

所以,个人总结以上的内容,

  • 如果要100%可靠,就要在onPause 中保存数据(一般就是保存在sp当中)
  • onPuase 中保存持久化的数据,比如用户输入
  • onSaveInstanceState 一般建议保存Activity的动态状态值(比如组件宽高)、允许丢失的数据

以上内容参考CSDN文章

62、如果不给view设置id,会对状态保存有什么影响?

点击看答案

首先看focus:

1
2
3
4
5
6
7
8
9
10
11
12
13
// save the focused view id
View focusedView = mContentParent.findFocus();
if (focusedView != null) {
if (focusedView.getId() != View.NO_ID) {//注意这里,如果当前焦点view有设置id,才会进入到下面
outState.putInt(FOCUSED_ID_TAG, focusedView.getId());//特别存储当前焦点view的id值
} else {
if (false) {
Log.d(TAG, "couldn't save which view has focus because the focused view "
+ focusedView + " has no id.");
}
}
}

再看View的保存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
if (mID != NO_ID && (mViewFlags & SAVE_DISABLED_MASK) == 0) {//只有有id的情况下才能进入到里面,添加view的状态
mPrivateFlags &= ~PFLAG_SAVE_STATE_CALLED;
Parcelable state = onSaveInstanceState();//调用view自己的onSaveInstanceState方法
if ((mPrivateFlags & PFLAG_SAVE_STATE_CALLED) == 0) {
throw new IllegalStateException(
"Derived class did not call super.onSaveInstanceState()");
}
if (state != null) {
// Log.i("View", "Freezing #" + Integer.toHexString(mID)
// + ": " + state);
container.put(mID, state);
}
}
}

可以看出,如果没有设置id,那么在在Activity调用 onSaveInstantceState 时,就没法保存它的状态到SparseArray中(这个SparseArray再保存到Bundle中),并且,如果当前它是焦点View,也没办法将焦点状态保存到 记录在 Bundle 中。

但是,如果同类的view,在使用相同的id时,在取状态值的时候,就可能会出现问题,比如:你在应用中使用两个ScrollView,且都指定一样的id,那么在onSaveInstanceState时,后调用的那个则会覆盖掉之前的那个ScrollView的Scroll的值,导致在之后取出的时候,会让两个ScrollView的滑动进度总是一样。

以上内容参考自CSDN

63、adb工具有哪些主要功能

点击看答案

1、常见的可能就是adb install、adb uninstall

2、通过 adb pull 从手机拉取文件,或者 adb push

3、常用的adb功能就是 dumpsys 的功能,比如 activity、meminfo、电池、cpu安装包信息

adb功能

4、 adb shell ,很大的一个命令,run-as 可以在debug状态查看应用的存储空间

以上内容参考自csdn的博客

64、Android与 JS 通过 Webview 交互

点击看答案

这个交互其实就是Android 与 JS 互相调用的过程,即: 1)Android 调用 JS 代码;2) JS 调用Android代码

Android调用 JS 代码

  • 通过Webview 的 loadUrl()
  • 通过 Webview 的 evaluateJavascript()

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// html 文件
<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8">
<title>Carson_Ho</title>

// JS代码
<script>
// Android需要调用的方法
function callJS(){
alert("Android调用了JS的callJS方法");
}
</script>

</head>

</html>

Android 端调用:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
 public class MainActivity extends AppCompatActivity {

WebView mWebView;
Button button;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

mWebView =(WebView) findViewById(R.id.webview);

WebSettings webSettings = mWebView.getSettings();

// 设置与Js交互的权限
webSettings.setJavaScriptEnabled(true);
// 设置允许JS弹窗
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);

// 先载入JS代码
// 格式规定为:file:///android_asset/文件名.html
mWebView.loadUrl("file:///android_asset/javascript.html");

button = (Button) findViewById(R.id.button);


button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {

// 第一种方法:调用javascript的callJS()方法
mWebView.loadUrl("javascript:callJS()");


// 第二种方法:只需要将第一种方法的loadUrl()换成下面该方法即可
mWebView.evaluateJavascript("javascript:callJS()", new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
//此处为 js 返回的结果
}
});

}
});

// 由于设置了弹窗检验调用结果,所以需要支持js对话框
// webview只是载体,内容的渲染需要使用webviewChromClient类去实现
// 通过设置WebChromeClient对象处理JavaScript的对话框
//设置响应js 的Alert()函数
mWebView.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onJsAlert(WebView view, String url, String message, final JsResult result) {
AlertDialog.Builder b = new AlertDialog.Builder(MainActivity.this);
b.setTitle("Alert");
b.setMessage(message);
b.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
result.confirm();
}
});
b.setCancelable(false);
b.create().show();
return true;
}

});
}
}

两种方法比较:

调用方式 优点 缺点 使用场景
loadUrl 方便 效率低,获取返回值麻烦 不需要获取返回值,对性能要求低
evaluateJavascript 效率高 Android 4.4 以上才可用 Android 4.4以上

注意,js代码调用一定要在 onPageFinished() 回调之后才能调用,否则不会调用

JS 调用 Android 代码

  • 通过 Webview 的 addJavascriptInterface()
  • 通过 WebViewClient 的 shouldOverrideUrlLoading 方法拦截 url
  • 通过 WebChromeClient 的 onJsAlert()、onJsConfirm()、onJsPrompt() 方法回调拦截 JS 对话框 alert()、confirm() 、prompt() 消息

其中,addJavascriptInterface 方式和 shouldOverrideUrlLoading 拦截方式就不提了,主要比较下第三种:

方法 作用 返回值 备注
alert 弹出警告框 在文本加入\n可换行
confirm 弹出确认框 两个返回值 返回 布尔 值
true表示确认,false 表示取消
prompt 弹出输入框 任意设置返回值 点击“确认”,返回输入框中的值,点击取消,返回null

以上内容参考自 简书上的博客

65、Webview 缓存及预加载方案

点击看答案

Webview 存在的问题

  • 页面资源加载缓慢,H5页面的资源会比较多,并且,请求都是串行的
  • js 解析渲染慢
  • 产生很多的网络请求,耗费流量

解决方案

其他的都先不说,我个人感兴趣的是自身构建缓存:

  1. 事先将更新频率较低、常用 或 固定的 H5 静态资源文件(如 JS、CSS文件、图片等)放到本地
  2. 拦截 H5 页面的资源网络请求,并进行检测
  3. 如果检测到本地具有相同的静态资源,就直接从本地读取进行替换,而不发送该资源的网络请求到服务器

具体实现:重写 WebViewClient 的 shouldInterceptrRequest 方法,当向服务器访问这些静态资源是进行拦截,检测到是相同的资源,则用本地资源替代:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// 假设现在需要拦截一个图片的资源并用本地资源进行替代

mWebview.setWebViewClient(new WebViewClient() {
// 重写 WebViewClient 的 shouldInterceptRequest ()
// API 21 以下用shouldInterceptRequest(WebView view, String url)
// API 21 以上用shouldInterceptRequest(WebView view, WebResourceRequest request)
// 下面会详细说明

// API 21 以下用shouldInterceptRequest(WebView view, String url)
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {

// 步骤1:判断拦截资源的条件,即判断url里的图片资源的文件名
if (url.contains("logo.gif")) {
// 假设网页里该图片资源的地址为:http://abc.com/imgage/logo.gif
// 图片的资源文件名为:logo.gif

InputStream is = null;
// 步骤2:创建一个输入流

try {
is =getApplicationContext().getAssets().open("images/abc.png");
// 步骤3:获得需要替换的资源(存放在assets文件夹里)
// a. 先在app/src/main下创建一个assets文件夹
// b. 在assets文件夹里再创建一个images文件夹
// c. 在images文件夹放上需要替换的资源(此处替换的是abc.png图片)

} catch (IOException e) {
e.printStackTrace();
}

// 步骤4:替换资源
WebResourceResponse response = new WebResourceResponse("image/png",
"utf-8", is);
// 参数1:http请求里该图片的Content-Type,此处图片为image/png
// 参数2:编码类型
// 参数3:存放着替换资源的输入流(上面创建的那个)
return response;
}

return super.shouldInterceptRequest(view, url);
}


// API 21 以上用shouldInterceptRequest(WebView view, WebResourceRequest request)
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {

// 步骤1:判断拦截资源的条件,即判断url里的图片资源的文件名
if (request.getUrl().toString().contains("logo.gif")) {
// 假设网页里该图片资源的地址为:http://abc.com/imgage/logo.gif
// 图片的资源文件名为:logo.gif

InputStream is = null;
// 步骤2:创建一个输入流

try {
is = getApplicationContext().getAssets().open("images/abc.png");
// 步骤3:获得需要替换的资源(存放在assets文件夹里)
// a. 先在app/src/main下创建一个assets文件夹
// b. 在assets文件夹里再创建一个images文件夹
// c. 在images文件夹放上需要替换的资源(此处替换的是abc.png图片

} catch (IOException e) {
e.printStackTrace();
}

// 步骤4:替换资源
WebResourceResponse response = new WebResourceResponse("image/png",
"utf-8", is);
// 参数1:http请求里该图片的Content-Type,此处图片为image/png
// 参数2:编码类型
// 参数3:存放着替换资源的输入流(上面创建的那个)
return response;
}
return super.shouldInterceptRequest(view, request);
}

});

}

这里可以参考文中的例子写一下

以上内容参考自简书上的博客

66、远程执行漏洞

点击看答案

漏洞描述

Android API level 16 及以前的版本存在远程代码执行安全漏洞,该漏洞源于程序没有正确限制使用 WebView.addJavascriptInterface 方法,远程攻击者可以通过使用 Java Reflection API 利用该漏洞执行任意 Java 对象的方法。简单地说就是通过 addJavascriptInterface 给 Webview 加入一个 JavaScript 桥接口,JavaScript 通过调用这个接口可以直接操作本地的 Java 接口。

漏洞执行原理

我们平常使用 addJavascriptInterface 为 webview 提供一个 java 对象,供其与 Java 端通信:

1
2
3
4
mWebView = new WebView(this);
mWebView.getSettings().setJavaScriptEnabled(true);
mWebView.addJavascriptInterface(this, "injectedObj");
mWebView.loadUrl(file:///android_asset/www/index.html);

但是,如果web页面并不干正经事,那么它就可以利用反射机制调用 Android API getRunTime 执行 shell 命令!看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
<html>
<body>
<script>
function execute(cmdArgs)
{
return injectedObj.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec(cmdArgs);
}

var res = execute(["/system/bin/sh", "-c", "ls -al /mnt/sdcard/"]);
document.write(getContents(res.getInputStream()));
</script>
</body>
</html>

此外,还能执行很多类似这样的操作,比如打电话、发送短信、安装木马 Apk等。

解决方案

API Level = 17 的Android系统

在高版本上,这个漏洞已经被修复,只有添加了 @JavascriptInterface 注解的方法才能被调用了。所以,在这个版本层次,我们只需要记得添加这个注解即可。

API Level <= 17 的Android系统

这就不建议使用 addJavascriptInterface 接口,以免带来不必要的安全隐患。如果一定要用,请注意:

  • 如果采用 https ,应进行证书校验防止访问的页面被篡改挂马
  • 如果使用 http 协议,应进行白名单过滤、完整性校验等防止页面被篡改
  • 如果加载本地html,应将html内置在Apk中,以进行完整性校验

移除Android系统内部的默认内置接口

Android 系统中 webkit 中默认内置的一个 searchBoxJavaBridge_ 接口同时存在远程代码执行漏洞,建议开发者移除:

1
2
3
removeJavascriptInterface("searchBoxJavaBridge_");
removeJavascriptInterface("accessibility");
removeJavascriptInterface("accessibilityTraversal");

以上内容参考自腾讯云上的博客

第一个系统进程(init)

Android设备的启动必须经历3个阶段: Boot Loader、Linux Kernel 以及 Android 系统服务,默认情况下它们都有各自的启动画面。作为Android 中第一个被启动的进程,init的 PID 的值为 0,它通过解析 init.rc 脚本来构建出系统的初始运行形态——其中最重要的就是启动 ServiceManager、Zygote 和 SystemServer。

Android 的“DNS服务器”——ServiceManager

ServiceManager 是Binder 机制中的“DNS服务器”,负责域名(某Binder服务在 ServiceManager 注册时提供的名称) 到IP地址(由底层Binder驱动分配的值)的解析。

ServiceManager 所属class 是core ,并且带有 critical 选项,说明它是系统的关键进程——意味着如果进程不幸在4分钟内异常退出4次,则设备将重启并进入还原模式。当 ServiceManager 重启时,zygote、media、surfaceflinger 等都会被重启。

“孕育” 新的线程和进程——Zygote

这个单词是”受精卵”的意思,正如其名所示,Android 中大多数的应用进程和系统进程都是通过 Zygote 来生成的。它所在的程序名叫做”app_process” ,而不像 ServiceManager 一样在一个独立的进程中。在init 进程的帮助下,通过zygote 逐步建立起 SystemServer 的运行环境。

Android的“系统服务”——SystemServer

SystemServer 是 Android 进入 Launcher 前的最后准备。ZygoteInit 通过 Zygote.forkSystemServer 来生成一个新的进程,用于承载各系统服务。

我们知道,同一个程序中两个函数之间能够直接调用的根本原因是他们处于相同的内存区域中,因为在同一个内存区域中,虚拟地址的映射规则完全一致,所以A函数和B函数的调用关系很简单,但两个不同进程,他们是没有办法直接通过内存地址来访问到对方内部的函数或者变量的。

既然无法直接访问,那间接的方法就是Binder。如果通观Binder的各个元素,就会惊奇地发现它和 TCP/IP 网络有很多相似之处:

  • Binder 驱动 -> 路由器
  • Service Manager(本质也是个服务器) -> DNS(本质也是个服务器)
  • Binder Client -> 客户端
  • Binder Server -> 服务器

TCP/IP中一个典型的服务连接过程如下图所示:

TCP/IP连接

这个简化的流程图有以下几个步骤:

  • Client 向dns 查询 google.com 的ip地址。
    显然,Client 一定得先知道DNS 的 IP地址,才能向它发起查询,DNS 服务器的ip设置是在接入网络前就已经设置完成的。当然,client向DNS 查询 ip地址不一定需要,因为如果已经知晓server的ip,就无需这一步,从而加快访问速度;

  • DNS 将查询结果返回 Client。
    Client的Ip地址对于 DNS 是必须的,不过这些信息会封装在 TCP/IP 包中。

  • Client 发起连接。这里我们没有提及Router的作用,因为它所负担的责任是将数据包投递到用户设定的目标IP中,它是整个通信结构中的基础。

而对于Binder来说,Binder的 DNS (Service Manager)也不是必须的——前提是客户端能记住它要访问的进程的 Binder 标志(IP地址),尤其要注意的是,这个标志是个“动态IP”,意味着即使客户端记住了本次通信过程中目标进程的唯一标志,下一次访问仍然需要重新获取。因此,Service Manager 这个 DNS 的还是挺有必要的。

既然Service Manager是 DNS 服务器,那么它的 IP 地址是多少呢?Binder 机制对此作了特别规定,Service Manager在Binder通信中唯一标志用于都是0。

智能指针

进程间的数据传递载体——Parcel

关于进程间如何传递,我们可以使用2个生活例子来类比:

  • 用快递寄衣服:虽然快递种类比较多,但是无论用哪个快递,用哪种运输方式,“衣服”本身始终是没有变过的,接收人拿到的还是原来那件衣服。
  • 通过电子邮件发送图片:在接收人看到邮件中的图片时,我们无法确认这张图片在传输过程中被复制了多少次,但是可以肯定的是,对方看到的图片和原始图片是一样的。

进程间通信的数据传递类似于第2种情况,如果只是一个int型数值,不断复制直到目标进程即可;如果是一个对象呢?我们直到,同一个进程中对象的传递实质上是传递了一个内存地址,但是在跨进程的情况下就无能为力了,因为采用了虚拟内存机制,两个进程都有自己独立的内存地址空间,所以跨进程传递的地址空间是无效的。

进程间的数据传递是Binder 机制中的重要环节,而担负这一重任的就是Parcel。 Parcel 的直译是 “打包” ,上面提到,进程间数据传递直接传送对象的地址是行不通的,那把对象在进程 A 中占据的内存相关数据打包起来,然后寄送到内存 B 中,由 B 在自己的进程空间中“复现”这个对象,是否可行? Parcel 就具有这种打包能力。

Parcel 提供了很多接口方便程序使用,他可以存储多种类型的数据:

  • 原始数据类型 以及 原始数据类型数组
  • Parcelable
  • Bundle
  • Active Objects

通常我们存入Parcel 的是对象的内容,而 Active Objects 写入的则是他们的特殊标志引用。所以从 Parcel 中读取这些对象时,大家看到的并不是重新创建的对象实例,而是原来那个被写入的实例,能够以这种方式传递的对象目前主要有两类: Binder 以及 FileDescriptor (Linux 中的文件描述符)。

  • Untyped Containers

它是用于读写标准的任意类型的 Java 容器,包括 : writeArray(Object[])/readArray(ClassLoader)、writeList(list)/readList(list)

Parcel 可以用集装箱来类比,理由如下:

  • 货物无关性: 不排斥运输的货物种类,电子产品、汽车等都可以
  • 不同的货物需要不同的打包和卸货方案: 比如运载易碎物品和坚硬物品的装箱和卸货方式就有很大的不同。

值得注意的是,Parcel 存/取 数据的方式都是一一对应的,如 writeByte(byte)/readByte()

  • 远程运输和组装: 集装箱的货物一般需要跨洋,这类似于 Parcel 的跨进程。不过集装箱运输公司本身并不负责所运输货物的组装,而 Parcel 会依据协议为接收方提供还原冤死数据对象的业务。

Parcel 的工作方式(书上没有,自己添加)

Parcel 的 Parcel.obtain() 方法可以获取一个Parcel 对象,系统预先产生了一个大小为6的 Parcel 池 sOwnedPool,在obtain 操作时,如果 sOwnedPool 中还有现成的 Parcel 对象,则直接利用,否则通过 new Parcel(0) 创建 Parcel 对象。

Parcel.java 实际上只是一个简单的中介,它的主要内容都是 JNI 层的 Parcel 实现的。Parcel 对象的初始化过程只是简单地给各个变量赋初始值,并没有设想中的内存分配动作,因为 Parcel 遵循的是“动态扩展”的内存申请原则,只有在需要时才申请内存,避免资源浪费。

Parcel 提供数据当前位置的值 dataPosition,类似于游标。每当存储新数据时,都是从 dataPosition 位置接着往后存储,存储新数据时,会判断当前空间是否足够,如果不足,则申请新的空间(个人根据文中内容理解的,不太确定是否正确)。

Binder驱动与协议

Android 是 linux 内核的,因而 Binder 驱动也是标准的 linux 驱动,具体而言,Binder驱动会把自己注册成一个 misc device,并向上层提供 /dev/binder节点——但是它并不对应真实的硬件设备。Binder 驱动运行于内核态,可提供 open()、ioctl()、mmap() 等常用文件操作。

Android 系统为什么把 Binder 注册成 misc device 类型的驱动呢?因为 linux 字符设备通常要通过 alloc_chrdev_region()、cdev_init() 等操作才能在内核中注册自己;而 misc 类型驱动相对简单,只需要 misc_register() 就可轻松解决。

Binder 驱动为上层提供了6个接口,但一般文件操作用到的 read() 和 write() 则没有出现,这是因为它们的功能完全可以用 ioctl() 和 mmap() 来代替,并且会更灵活。这6个接口中使用得最多的是 binder_ioctl,binder_mmap 和 binder_open,,以下分别介绍这三种接口。

打开Binder驱动——binder_open

上层进程在访问 Binder 驱动时,首先需要打开 /dev/binder 节点,这个操作最终的实现是在 binder_open() 中,在这个方法中,会创建一个 binder_proc 实体,这个实体用于记录各种管理数据(Binder 驱动会在 /proc 系统目录下生成各种管理信息),并且,每个进程都有独立的记录。

在完成proc 的初始化之后,就会把这个 proc 加入到 Binder 的全局管理中,这个过程涉及资源互斥,因而需要使用保护机制。到目前为止,Binder 驱动已经为用户创建了一个它自己的 binder_proc 实体,之后用户对Binder 设备的操作都以这个对象为基础。

binder_mmap

对于 Binder 驱动来说,上层用户调用的 mmap() 最终对应了 binder_mmap()操作(应用程序最多只能申请 4M 的空间,如果超出这个大小,不会退出或者报异常,而只会满足用户 4M 的请求),那么Binder 采用 mmap 的目的是什么呢?我们知道,mmap() 可以把设备指定的内存块直接映射到应用程序的内存空间中,但Binder 本身并不是硬件设备,而是基于内存的“伪硬件”,那么它映射了什么内存块到应用程序中呢?

假设有连个进程A和B,其中进程B通过 open() 和 mmap() 与Binder驱动建立了联系,如下图:

进程B连接Binder

可以看到 :

  1. 对于进程B而言,通过mmap()返回值得到一个内存地址(当然是虚拟地址),这个地址最终会指向物理内存的某个位置(通过虚拟内存转换)。
  1. 对于Binder驱动而言,它也有个指针(binder_proc->buffer)指向某个虚拟内存地址,这个地址转换后,与进程B指向的物理内存地址位于同一个位置。

个人理解:进程B执行 mmap() ,最终是通过 Binder 的 binder_mmap() 来实现,在 B 拿到这块内存后(当然是经过虚拟内存转换后的虚拟内存地址),Binder 驱动同时将这块内存赋值给了 binder_proc->buffer。这样,Binder和应用程序就拥有了若干公用的物理内存块,它们对着各自内存地址的操作,实际是在同一块内存中执行,这时候我们再把A进程加入进来,如下图:

进程A复制数据

这时候,左半部分没有变化,右半部分Binder驱动通过copy_from_user(),把A进程中某段数据复制到其binder_proc->buffer所指向的内存空间,这时候我们惊喜发现,binder_proc->buffer在物理内存中的位置和进程B是共享的,进而,B进程可以直接访问到这段数据,也就是说,Binder驱动只用了一次复制,就实现了进程A和B之间的数据共享。

以上通过 mmap 映射的映射区是 只读的。

binder_ioctl

这是 Binder 接口函数中工作量最大的一个。前面提到过,Binder 并不提供 read() 和 write() 等常规文件操作,因为 ioctl 完全可以替代它们。它主要提供了以下命令:

  • BINDER_WRITER_READ: 读写操作,可以用此命令向 Binder 读取或写入数据
  • BINDER_SET_MAX_THREAD: 设置支持的最大线程数,因为客户端可以并发向服务器端发送请求,如果Binder 驱动发现当前的线程数量已经超过设定值,就会告知 Binder server 停止启动新的线程。
  • BINDER_SET_CONTEXT_MGR: Service Manager 专用,让它把自己设置为“Binder”大管家。系统中只有一个 Service Manager。

Service Manager

Service Manager(后面简称 SM) 也就是Binder 中的 “DNS服务器”,既然是DNS,那么在用户可以浏览网页之前就必须就位,因此SM在有人使用Binder之前就处于正常工作状态。SM 的主要工作:

  • 从Binder驱动读取消息
  • 调用binder_parse 处理解析消息
  • 不断循环,而且永远不会主动退出,除非出现致命错误

它提供的服务应该至少包括以下几种:

  • 注册——当一个Binder Server 创建后,它们要将自己的相关信息告知 SM 备案
  • 查询——应用程序可以向 SM 发起查询请求,已获知某个 Binder Server 对应的句柄。

SM 的查询过程很简单,主要是调用 do_find_server 遍历内部列表,并返回目标 Server;注册 Binder server 也很简单,首先在 SM 维护的数据列表中查找是否已经有对应的节点存在,,如果没有,就创建一个新的节点记录这个 Server。

实际上,我们获取 SM 也很简单,只需要以下几步:

  1. 打开Binder设备
  2. 执行mmap
  3. 通过Binder驱动向 SM 发送请求(SM 的 handle是 0)
  4. 获得结果

Binder 客户端

Binder 的最大消费者是Java层的应用程序,但是在各种上层的应用场景中切换“过于丝滑”,因此我们很少能感觉到Binder的存在,但是我们能够通过Android的四大组件的行为看出蛛丝马迹:

  • Activity:通过 startActivity 可以启动目标进程,不论它是不是属于这个应用。
  • Service:任何应用程序都可以通过startService 或者 bindService 来启动特定的服务,而不论后者是不是跨进程的。
  • BroadCast:任何应用都可以通过 sendBroadcast 来发送一个广播,且无论广播接收者是不是在同一个进程中。

组件的上述操作中,多数并不会特别指明要由哪个目标应用程序来响应请求,它们只需要通过Intent表达意愿,然后由系统找出最匹配的应用进程完成工作。为了更明确地说明个中的进程间通信,这里以binderService举例说明:

  1. 首先Application1 填写Intent,调用 bindService 发出请求
  2. 在Application1的运行空间中收到 bindService 请求,这时候会与 ActivityManagerService(AMS),这就需要得到AMS的Binder句柄,就涉及到进程间通信了(需要ServiceManage.getService),拿到句柄后,程序才能真正向它发起请求。
  3. AMS基于“最优匹配策略”,从其存储的所有服务组件中找到最符合Intent的一个,然后向它发送Service绑定请求(这也是进程间通信),如果目标进程还不存在的话,AMS还要负责将其启动
  4. “被绑定”的服务进程需要响应绑定,并在完成任务后通知AMS,然后由后者回调发起请求的Application1(回调接口是ServiceConnection)。

Server 服务端

在建立服务之后,可以有两种方式向外面提供服务:

  • Server在ServiceManager中注册,这样,调用者只需要通过 ServiceManager.getService(NAME)就可以获得句柄,随后与之通信。
  • 所谓的“匿名Server”,并不需要在ServiceManager中注册,那么Client是如何访问的呢?其实它通过其他Server作为中介,没错,就是通过一个“第三方”实名的Server,调用者首先通过ServiceManager获取这个实名的server,在由它提供匿名者的 Binder 句柄。

Binder 优点

基于自己的理解而言,Binder具有以下优点:

  • 性能较好

Binder只需要拷贝一次数据,仅次于共享内存(一次都不要),消息队列和管道采用存储-转发方式,即数据先从发送方缓存区拷贝到内核开辟的缓存区中,然后再从内核缓存区拷贝到接收方缓存区,至少有两次拷贝过程。

  • 稳定性好

基于C/S架构在逻辑上更清晰,client有需求,Server完成。共享内存实现起来复杂,各方没有客户端与服务端之别,要考虑并发问题以及可能出现的死锁。

  • 安全性好

传统 Linux IPC 的接收方要么无法获得对方进程可靠的 UID/PID ,从而无法鉴别对方身份;要么只能由用户在数据包里填入UID/PID。Android 为每个应用分配了自己的UID,前面提到 C/S 架构,Server 会根据权限控制策略判断 Client 的请求是否满足权限。并且Binder机制还有匿名 Binder ,压根无法直接获取句柄,安全性更好。

  • 使用简单。

获得句柄之后,就像调用本地方法一样方便。并且Linux的IPC方式使用C语言,而Android应用层主要使用Java,这可能也是个因素。

概述

在许多情况下,让计算机同事去做几件事情,不仅是因为计算机的运算能力强大了,还有一个很重要的原因是计算机的运算速度与它的存储和通信子系统的差距太大,大量的时间都花费在磁盘I/O 、网络通信或者数据库访问上。

硬件的效率与一致性

高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但也为系统引入了一个新问题:缓存一致性(Cache coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主存(Main memory),当多个处理器的运算任务都涉及同一块主内存区域时,可能导致各自的缓存数据不一致,此时,同步回到主存时以谁的缓存数据为准呢?

除高速缓存外,为了使处理器内部运算单元尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但是并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。因此,如果某个计算任务依赖另一个计算任务的中间结果,那么其顺序性不能依靠代码的先后顺序来保证。

Java 内存模型

Java 内存模型(Java Memory Model, JMM) 是用来屏蔽各种硬件和操作系统的内存访问差异。

主内存与工作内存

Java 内存模型的主要目标是定义程序中各个变量的访问规则,此处的变量(Variables) 包括 实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不存在竞争。

注意区别:如果局部变量是个refrence类型,它引用的对象在Java堆中可能被各个线程共享,但refrence本身在Java栈的局部变量表中,是线程私有的。

Java内存模型规定所有的变量都存储在主内存(Main Memory),每条线程还有自己的工作内存(Working Memory),这两个分别可以类比物理模型中的主内存以及处理器的高速缓存。线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。

关于副本拷贝,假设线程访问一个10MB 的对象,那会不会也拷贝出来呢?虚拟机基本上不会这么实现,只有对象的引用、对象中某个在线程中访问到的字段是可能存在拷贝的。还有,这里所讲的主内存、工作内存 与 前面讲的Java堆、栈、方法区等并不是同一个层次的内存划分,二者并没有关系。

内存间交互操作

Java 内存模型中定义了以下8种操作来完成从主内存拷贝到工作内存,以及从工作内存同步回主内存:

  • lock(锁定):对主内存变量标识为某一条线程独占。
  • unlock(锁定):对主内存变量标识为解锁。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存,以便后面的load操作。
  • load(载入):作用于工作内存变量,把read操作从主内存得到的变量值放入工作内存的变量拷贝中。
  • use(使用):作用于工作内存变量。把工作内存中一个变量的值传递给执行引擎。当虚拟机遇到一个需要使用到变量的值的字节码指令时执行这个操作。
  • assign(赋值):作用于工作内存变量。把一个从执行引擎接收到的值赋值给工作内存的变量。当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存变量。把工作内存中一个变量的值传送到主内存中,以便随后的write操作。
  • write(写入):作用于主内存变量。把store操作从工作内存中得到的变量的值放入主内存的变量中。

如果要把一个变量从主内存复制到工作内存,那就要顺序地执行 read 和 load 操作(只是顺序地执行,不保证连续执行,中间是可以插入其他指令的),如果要把变量从工作内存同步回主内存,就要顺序地执行 store 和 write 操作。并且,Java 内存模型要求执行上述操作必须满足下列条件:

  • read 和 load、store 和 write 必须成对出现,不许单独出现。
  • 变量在工作内存中改变了之后,必须要同步回主内存,即不允许丢弃它最近的 assign 操作;并且如果没有发生assign,则不允许同步回主内存。
  • 对一个变量 use、store 之前,必须先执行过了 assign 和 load 。
  • 一个变量在同一时刻只允许一条线程执行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次 lock 后,只有执行相同次数的 unlock 操作后变量才能被解锁。
  • 如果对一个变量执行 lock 操作,那会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量值。
  • 如果变量没有被 lock 锁定,那就不允许对它执行 unlock ,也不允许 unlock 一个被其他线程 lock 住的变量。
  • 对变量执行 unlock 之前,必须把此变量同步回主内存中(执行 store、write 操作)。

对于 volatile 型变量的特殊规则

volatile 可以说是Java虚拟机提供的最轻量级的同步机制。这里首先以不正式但通俗易懂的语言来介绍这个关键字的作用,当一个变量定义为 volatile 后,它将具备两种特性:

  • 第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对其他线程来说是可以立即得知的。而普通变量是做不到这一点的。
  • 第二个语义是禁止指令重排序优化。

关于volatile 变量的可见性,经常会有开发人员误解,认为 “volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是安全的”,因为事实上我们不能得出“基于 volatile 变量的运算在并发下是安全的” 这样的结论,因为虽然volatile变量在各个线程的工作内存中不存在一致性问题(由于每次使用之前都要先刷新,所以执行引擎看不到一致的情况,所以可以认为不存在一致性问题),但Java里面的运算并非原子操作,所以导致 volatile 变量的运算在并发下一样是不安全的。通过以下简单代码可以演示:

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
30
31
32
33
public static volatile int race = 0;
private static final int THREADS_COUNT = 20;

private static void increase() {
race++;
}

public static void main(String[] args) {

Thread[] threads = new Thread[THREADS_COUNT];

for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
//System.out.println(race);
}
}
});

threads[i].start();
}


//等待所有累加线程都结束
while (Thread.activeCount() > 1) {
Thread.yield();
}

System.out.println(race);
}

注意:我个人运行这段代码的时候,并不能正常打印,因为while循环中的 Thread.activeCount() 一直是2,只有把 for 循环中的 System.out.println(race) 放开才能打印,我的环境是: Android Studio 3.5 + jre1.8 。,如果并发正确的话,最终输出结果应该是 200000,但实际运行的结果大概率会是一个小于 200000 的数字。为什么呢?问题出在 increase() 方法中的 race++ 操作上,我们可以使用javap命令反编译这个函数的代码得到如下的字节码:

increase方法字节码

发现只有一行代码的increase方法在class文件中是由4条字节码指令构成的(return 不是由 race++ 产生的),所以很容易分析出并发失败的原因了:当getstatic 指令把 race 的值取到操作栈顶时,volatile 可以保证 race 的值是正确的、最新的,但在执行 iconst_1、iadd 这些指令的时候,其他线程可能已经把 race 的值增大了,所以最后执行 putstatic 指令后可能把较小的 race 值同步回了主内存中。

客观地说,此处用字节码来分析问题仍然是不严谨的,因为即使编译出来只有一条字节码指令,也并不意味着这条指令就是一个原子操作,一条字节码在执行时,解释器往往要运行多行代码才能实现它的语义。如果是编译执行,一条字节码可能会转化成若干条本地机器码指令。但考虑阅读方便,并且字节码已经够说明问题,所以此处选用字节码分析。

以上代码说明volatile 在并发运行中可能出现的问题,但是如下代码场景就很适合使用 volatile 变量来控制并发,当shutdown 方法调用时,能保证所有线程中执行的 doWork() 方法都能停下来:

1
2
3
4
5
6
7
8
9
10
11
volatile boolean stopThread;

public void shutdown(){
stopThread = true;
}

public void doWork(){
while(!stopThread){
//do stuff
}
}

volatile 禁止指令重排序优化仍然不太容易理解,通过以下伪代码来看看为何指令重排序会干扰程序的并发执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Map configOptions;
char[] configText;
//此变量必须定义为 volatile

//假设以下代码在线程 A 中执行,模拟读取配置信息,读取
//完成后,将 initialized 设置为 true 以通知其他线程
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText,configOptions);
initialized = true;

//假设以下代码在线程 B 中执行,等待 initialized 为 true ,
//代表线程 A 已经把配置信息初始化完成
while(!initialized){
sleep();
}
//使用线程 A 中初始化好的配置信息
doSomethingWithConfig();

这个场景十分常见,只是我们在处理配置文件时一般不会出现并发而已。如果 initialized 没有使用 volatile 修饰,就可能由于指令重排序优化,导致 initialized = true 被提前执行(指令重排序优化是机器级的优化操作,这里说的提前执行是指对应的汇编代码提前执行),这样线程 B 中使用配置信息的代码就可能出现错误,而volatile 可以避免此类情况发生。以下再举个例子来说明 volatile 关键字是如何禁止指令重排序优化的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Singleton{
private volatile static SingleTon instance;

public static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}

public static void main(){
Singleton.getInstance();
}
}

编译后,这段代码对instance变量赋值部分如下代码所示:

单例编译后代码清单

可以对比 instance 有被volatile修饰和没有被修饰的编译后的代码,会发现,关键变化在于有 volatile 修饰的变量,在赋值(mov %eax,0x150(%esi) 这句就是赋值操作),之后会多执行一个 lock 操作,这个操作是一个内存屏障,令重排序时不能把后面的指令重排序到内存屏障之前的位置。只有一个cpu访问内存时,并不需要内存屏障;但如果有两个或更多cpu访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性。这操作也相当于做了一次 store 和 write 操作,引起其他cpu(或内核)对于这个变量的无效化,因此保证了 volatile 变量的修改对其他cpu立即可见。

在某些情况下,volatile 的同步机制的性能确实要优于锁,但是虚拟机对锁实行许多消除和优化,所以我们很难量化地认为 volatile 的同步机制比锁快多少。但是可以肯定的是:volatile 变量的读操作与普通变量几乎没差别,但写操作可能会慢一些,因为它需要需要在本地代码中插入内存屏障保证处理器不乱序执行。

long 和 double 型变量的特殊规则

long 和double 都是64位数据类型,内存模型允许虚拟机将没有被 volatile 修饰的64位数据类型的读写操作分为两次32位操作来进行,可能会导致某些线程读到”半个变量”的数据,不过这种情况十分罕见(目前商用Java虚拟机不会出现),所以了解这回事即可。

原子性、可见性 与 有序性

整体回顾一下Java内存模型,Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性3个特征来建立的。

  • 原子性(Atomicity):read、load、assign、use、store 和 write 操作是保证原子性的,基本数据类型的访问读写也是具有原子性的。如果要保证更大范围的原子性,Java 内存模型还提供了 lock 和 unlock 来满足这种需求。

  • 可见性。可见性指一个线程修改了共享变量的值,其他线程能立即得知这个修改。除了volatile 之外,Java还有两个关键字能实现可见性,即 synchronize 和 final 。同步块的可见性是由 “对一个变量执行unlock操作前,必须把此变量同步回主内存中” 获得的。而final修饰的字段在构造器中一旦初始化完成,并且构造器没有把 “this”的引用传递出去(this引用逃逸是很危险的,其他线程可能通过这个引用访问到”初始化了一半”的对象),那在其他线程中就能看到 final 字段的值。

  • 有序性。Java 程序中有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果从一个线程观察另一个线程,所有操作都是无序的。前者指“线程内表现为串行语义” ,后者指“指令重排序”和“工作内存与主内存同步延迟” 现象。Java 语言提供了 volatile 和 synchronized 来保证线程之间操作的有序性,前者本身包含了指令重排序的语义;后者通过 “一个变量在同一个时刻只允许一条线成对其进行lock操作”决定了持有同一个锁的两个同步块只能串行进入。

先行发生原则

这块理论性的总结性的内容,个人觉得就先不写了。

Java 线程

线程的实现

这块的内容在操作系统的内容中有描述:

  1. 使用内核线程实现(每个内核线程对应一个轻量级进程);
  2. 使用用户线程实现(创建销毁开销小,没有内核支援,处理诸如阻塞等问题异常困难);
  3. 使用用户线程+轻量级进程混合实现(即内核线程和用户线程一起使用,n: m 的关系)。

Java 线程调度

线程调度的方式主要有两种: 协同式线程调度(Cooperative Threads-Scheduling) 和 抢占式线程调度(Preemptive Threads-Scheduling)。前者靠线程自觉,线程执行时间由线程自己控制,后者每个线程由系统分配时间,Java 使用后者。

虽然Java线程调度是系统自动完成的,但是我们可以给线程设置优先级来“建议”系统给某些线程多一些时间。但给线程设置优先级还是不靠谱的,原因如下:

  1. Java 线程是通过映射到系统的原生线程上来实现的,不同的系统上的优先级与Java的线程优先级等级基本上对应不上。可能你在Java中指定的2个优先级,在某操作系统上映射成同一个优先级。

  2. 优先级可能会被系统自行改变。

状态转换

Java 语言定义了5种线程状态,在任一时间点,一个线程只能有且只有其中的一种状态:

  • 新建(New):创建后尚未启动。
  • 运行(Runnable):可能在执行,也有可能在等待 CPU 分配时间。
  • 无限期等待(Waiting):这种状态下不会被 CPU 分配时间,要等其他线程显式地唤醒。

以下方法会让线程陷入无限期等待:
没有设置 Timeout 参数的 Object.wait() 方法。
没有设置 Timeout 参数的 Thread.join() 方法。
LockSupport.park() 方法。

  • 限期等待(Timed Waiting):这种状态下不会被 CPU 分配时间,不过无需等待其他线程显式唤醒,在一定时间后由系统自动唤醒。

以下方法会让线程进入限期等待:
Thread.sleep() 方法。
设置了 Timeout 参数的 Object.wait() 方法。
设置了 Timeout 参数的 Thread.join() 方法。
LockSupport.parkNanos() 方法。
LockSupport.parkUtil() 方法。

  • 阻塞(Blocked):阻塞状态是在等待获取一个排他锁。

  • 结束(Terminated):线程已经执行结束。

线程状态转换图如下:

线程状态转换图

Android 应用程序由 Activity、Service、Broadcast Receiver 和 Content Provider 四中组件构成,它们可能运行在不同的进程中。此外,各种系统组件也运行在独立的进程中,如 Activity 管理服务 ActivityManagerService 和 package 管理服务 PackageManagerService 都运行在系统进程 System 中。

Android 是基于 Linux 内核开发的,Linux 内核提供了丰富的进程间通信机制:管道(Pipe)、信号(Signal)、消息队列(Message)、共享内存(Share Memory) 和 套接字(Socket) 等。但Android 系统并没有采用这些传统的进程间通信机制,而是使用了新的机制——Binder。与传统的进程间通信机制相比,Binder 进程间通信机制在进程间传输数据时,只需要执行一次复制操作,不仅提高了效率,并且节省了内存空间。

使用共享内存在进程间传输数据的时候,虽然也只需要进行一次复制操作,但是它一般要结合其他的进程间通信机制来同步信息。

Binder 进程间通信机制采用 C/S 通信方式,提供服务的进程称为 Server 进程,而访问服务的进程称为 Client 进程,同一个 Server 进程可以同时运行多个组件来向 Client 提供服务;而同一个 Client 进程也可以同时向多个 Service 组件请求服务。Service 组件启动时,会将自己注册到一个 Service Manager 组件中,以便 Client 组件可以通过 Service Manager 组件找到它。Binder 进程间通信机制中涉及了 Client、Service、Service Manager 和 Binder 驱动程序四个角色的关系如下图所示:

Binder通信机制

Client、Service 和 Service Manager 都运行在用户控件,而Binder 驱动程序运行在内核控件。Client、Service 和 Service Manager 均是通过系统调用 open(打开)、mmap(内存映射)、ioctl(在用户空间,使用ioctl系统调用来控制设备) 来访问设备文件 /dev/binder ,从而实现与Binder驱动的程序的交互,而交互的目的就是为了能够间接执行进程间通信。

概述

并发处理的广泛应用使得 Amdahl 定律替代摩尔定律成为计算机性能发展源动力的根本原因,也是人类“压榨”计算机运算能力的最有力武器。

线程安全

线程安全的代码都必须具备一个特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程问题,更无须自己采取任何措施来保证多线程的正确调用。

Java 线程安全

为了更深入地理解线程安全,在这里我们可以不把线程安全当做一个非真即假的二元排他选项来看待。按照线程安全的“安全程度”由强至弱来排序,我们可以将Java语言各种操作共享的数据分为:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

不可变

不可变(Immutable) 的对象一定是线程安全的,只要一个不可变对象被正确地创建构建出来(没有发生this引用逃逸的情况),那其外部的可见状态永远也不会改变,永远也不会看到它在多个线程之中处于不一致的状态。

Java 语言中,如果共享数据是一个基本数据类型,只要在定义时使用final关键字就可以保证它是不可变的;如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响,有很多途径去实现这个目的,最简单的就是把对象中带有状态的变量都声明为final,这样,在构造函数结束之后,它就是不可变的。

Java API 中复合不可变要求的类型,有String、枚举类型、Long、Double以及BigInteger、BigDecimal等大数据类型,但同为Number的子类型的原子类 AtomicInteger 和 AtomicLong 则并非不可变的。

绝对线程安全

绝对线程安全通常需要付出很大的,甚至有时候是不切实际的代价。在Java API 中标注自己是线程安全的类,大多数都不是绝对的线程安全。我们可以通过一个不是“绝对线程安全”的线程安全类来看看这里的“绝对”是什么意思。

Java程序员基本上都认为 java.util.Vector 是一个线程安全的容器,因为它的add、get、size等方法都被 synchronized 修饰的,尽管这样效率很低,但确实是安全的。但是,即使所有方法都被修饰成同步,也并不意味着调用它的时候永远不需要同步手段了,看如下测试代码:

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
30
31
32
33
private static Vector<Integer> vector = new Vector<Integer>();

public static void main(String[] args) {
while (true) {
for (int i = 0; i < 10; i++) {
vector.add(i);
}

Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
});

Thread prindThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
System.out.println(vector.get(i));
}
}
});

removeThread.start();
prindThread.start();

//不要同时产生过多线程,否则导致操作系统假死
while (Thread.activeCount() > 20) ;
}
}

这段代码运行过程中会出现 ArrayIndexOutOfBoundsException,但是在我自己写代码验证的时候,感觉这个错误还是很容易被忽略的,运行了好长时间都还在一直输出,但其实早就报错了,但不知道怎么程序还是正常运行。这点在自己验证的时候要注意一下,我的运行环境是:Android studio 3.5 + jre 1.8 。

可能你一开始也会和我一样懵逼了,这 Vector 里面每个方法都被 synchronized 关键字修饰,这种情况下调用方法锁定的是对象,虽说synchronized 可以重入,但是上述代码添加、删除、遍历 可是在3个线程当中啊,压根就是互斥的啊,怎么会报错呢?

原来,我们忽略了一个重要因素:原子操作。在 prindThread 线程中,for 循环会读取 vector.size ,它会先获取锁,然后读取值,假设值是 10,之后释放锁(注意,问题就在这了),接下来print的时候,假设当前i是7,通过 vector.get(i) 读取元素的时候,会再次加锁,但是由于此时 vector 的size 不知道已经变成多少了,如果此时变成了 6,那么这个 get 操作就会发生越界了。

如果remove线程恰好在一个错误的时间里删除了一个元素,导致序号i已经不再可用的话,再用vector.get(i) 访问就会抛出 ArrayIndexOutOfBoundsException。如果要保证这段代码的正确运行,书上给出的方案是把两个thread改成如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (vector){
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}

}
});

Thread prindThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (vector){
for (int i = 0; i < vector.size(); i++) {
System.out.println(vector.get(i));
}
}

}
});

相对线程安全

相对线程安全就是我们通常意义上所讲的线程安全,它需要保证这个对象单独的操作是线程安全的,我们在调用的时候无需做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。Java语言中,Vector、HashTable、Collections的synchronizeCollection() 方法包装的集合等。

线程兼容

线程兼容指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。Java API 中大部分类都是属于线程兼容的,如 ArrayList 和 HashMap 等。

线程对立

线程对立是指无论调用端是否采取同步措施,都无法再多线程环境并发使用,如 Thread 类的suspend() 和 resume() 方法,如果两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发的话,无论是否进行了同步,都会存在死锁风险(这两个方法在jdk中已经被声明放弃了)。这类代码很少出现,我们也应该避免。

线程安全的实现方法

这听起来是代码编写的问题,但虚拟机提供的同步和锁机制也起到了非常重要的作用。只要读者了解了虚拟机线程安全手段的运作过程,自己去思考代码如何编写并不是一件困难的事情。

互斥同步

同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只能被一个(或者是一些,使用信号量的时候)线程使用,而互斥是实现同步的一种手段。互斥是因,同步是果;互斥是方法,同步是目的。

Java中最基本的互斥同步手段是 synchronize 关键字,经过编译后,会在同步快的前后分别形成 monitorenter 和 monitorexit 两个字节指令,这两个字节码都需要 reference 类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronize 指明了对象参数,那就是这个对象的 reference ;如果没有明确指定,那就根据 synchronize 修饰的是实例方法还是类方法,去取对应的对象实例或 Class 对象来作为锁对象。

根据虚拟机规范,执行 monitorenter 时首先尝试获取对象的锁,如果没有被锁定,或者当前线程已经拥有这个锁,就把锁的计数器 +1 ,相应地,monitorexit 时减 1 。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被释放。

这里有2点是要注意的:synchronize 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;其次,同步块在已进入的线程执行完之前,会阻塞后面的线程,前面提到过,Java的线程是映射到操作系统的原生线程上的,如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转到核心态中,因此状态转换需要耗费很多的处理器时间。对于代码简单的同步块(如被synchronize修饰的getter() 或 setter() 方法),状态转换消费的时间可能比代码执行时间还要长,所以 synchronize 是 Java 语言中一个重量级(Heavyweight)操作。

还可以使用 ReetrantLock 来实现同步,它的基本用法与 synchronize 相似,都具备线程重入性,只是 ReetrantLock 表现为API语法层面的互斥锁(lock 和 unlock 方法配合 try/finally 语句块来完成),synchronize 表现为原生语法层面的互斥锁。不过,ReetrantLock 增加了一些高级功能,主要有以下3项:

  • 等待可中断:指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待。
  • 公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁:而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronize 是非公平的,ReetrantLock 默认也是非公平的。
  • 锁绑定多个条件是指一个 ReetrantLock 对象同时可以绑定多个 Condition 对象。而synchronize 中,锁对象的 wait 、notify或者 notifyAll 方法可以实现一个隐含的条件是:如果要和多于一个的条件关联的时候,就要额外地添加一个锁。而 ReetrantLock 无需这样做,只需要多次调用 newCondition 方法即可。

在JDK 1.6以前,synchronize 的吞吐量会随着竞争的激烈程度增加而急剧下降,但 ReetrantLock 却基本保持在平稳水平。但随着后面的优化,二者的性能基本上持平了,并且虚拟机在未来的性能改进中更倾向于原生的 synchronize ,所以还是提倡在 synchronize 能实现需求的情况下,优先考虑使用 synchronize 来同步

非阻塞同步

互斥同步最主要的问题就是线程阻塞和唤醒带来的性能问题,因此也称为阻塞同步,这是一种悲观的并发策略,总认为不去做正确的同步措施,就肯定会出问题。随着硬件指令集的发展(因为我们需要操作和冲突检测这两个步骤具备原子性,只能靠硬件来保证,如果使用互斥同步就失去意义了),我们有了另一个选择:基于冲突检测的乐观并发策略,通俗讲就是先操作,如果没有其他线程竞争,操作就成功了;如果有竞争产生了冲突,再采取其他补偿措施(常见的措施就是不断重试,直到成功)。这种策略无需把线程挂起,因此称为非阻塞同步。

指令集发展后,可以确保一个从语义上看起来需要多次操作的行为只需要一条处理器指令就能完成,其中最重要的指令为 :比较并交换(Compare-and-Swap) ,下文简称 CAS,它的语义是:内存地址V,旧的预期值 A,新值 B,当且仅当 V 符合旧预期值 A 是,处理器就用新值 B 更新 V,否则就不更新。

不妨拿一段在第 12 章中没有解决的问题代码来看看如何使用CAS操作来避免阻塞同步,我们曾通过这段20个线程自增 10000 此的代码来证明 volatile 变量不具备原子性,那如何才能让它具备原子性?把 “race ++ “ 操作或 increase() 方法用同步块包裹起来当然是一个办法,但如果改成如下代码,效率会提高很多:

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
private static AtomicInteger race = new AtomicInteger(0);
private static final int THREADS_COUNT = 20;

private static void increase(){
race.incrementAndGet();
}

public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0;i< THREADS_COUNT;i++){
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0;i< 10000;i++){
increase();
}
}
});

threads[i].start();
}

while (Thread.activeCount() > 1){
Thread.yield();
}

System.out.println(race);
}

运算结果: 200000

使用AtomicInteger代替int后,程序输出了正确地结果,一切要归功于incrementAndGet 方法的原子性,它的实现非常简单:

1
2
3
4
5
6
7
8
9
public final int incrementAndGet(){
for(;;){
int current = get();
int next = current + 1;
if(compareAndSet(current,next)){
return next;
}
}
}

incrementAndGet 方法在一个无限循环中,不断尝试将一个比自己当前值大1的新值赋给自己,如果失败了,那说明在执行“获取-设置”操作的时候值已经有了修改,于是再次循环进行下一次操作,直到设置成功为止(设置成功才return 跳出循环)。

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A,这就有可能是这段期间它的值曾经被改成了B,后来又被改成了A,那 CAS 操作就会误认为它从来没被改变过,这称为 “ABA” 问题。如果需要解决 “ABA” 问题,改用传统的互斥同步会比原子类更高效。

无同步方案

如果一个方法本来就不涉及共享数据,那它自然无需任何同步措施。这种代码是天生线程安全的,这里简单介绍其中两类:

  • 可重入代码:这种代码可以在任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。可重入代码有一些共同特征,如 不依赖存储在堆上的数据和公用的系统资源,用到的状态量都是由参数中传入、不调用 非可重入的方法等。

  • 线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看这些共享数据代码是否能保证在同一个线程中执行,如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内。

符合这种特点的应用并不少见。一个应用实例就是web交互模型中“一个请求对应一个服务器线程”的处理方式

锁优化

JDK 1.6 实现了各种锁优化技术,如适应性自旋、锁消除、锁粗化、轻量级锁 和 偏向锁 等,这些技术都是为了在线程之间更高效地共享数据。

自旋锁与自适应自旋

在讨论互斥同步的时候,提到了阻塞对性能影响最大。虚拟机开发团队注意到在许多应用上,共享数据锁定状态只会持续很短时间,为这段时间去挂起和恢复线程不值得。我们可以让后面的请求锁的线程“稍等一下”,但不放弃处理器的执行时间,看锁是否被很快释放。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓自旋锁

自旋本身避免了挂起和恢复过程,但是它占用cpu时间,如果锁被占用的时间很短,这是值得的;反之,如果锁被占用的时间过长,则白白消耗处理器资源,因此需要有一定限度,限定自旋次数。

锁消除

如果代码上要求同步,但是被检测到不可能存在共享数据竞争,就可以对锁进行消除,锁消除的主要判定依据来源于逃逸分析的数据支持。也许读者会有疑问,变量逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员自己应该很清楚,怎么会在明知道不存在数据竞争的情况下要求同步呢?答案是:有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中普遍存在,来看看下面例子:

1
2
3
public String concatString(String s1,String s2,String s3){
return s1 + s2 + s3;
}

我们知道,String 是个不可变的类,对字符串的连接操作总是通过生成新的String来进行。JDK 1.5 前,String 连接会转化为 StringBuffer对象的连续 append 操作,1.5 及以后的版本会转化为 StringBuilder 对象的连续 append,即上述代码可能会变成以下的样子:

1
2
3
4
5
6
7
public String concatString(String s1,String s2,String s3){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

现在大家还认为这段代码没有涉及同步吗?每个 StringBuffer.append 方法中都有一个同步块,锁就是sb对象。虚拟机很快会发现它的动态作用域被限制在 concatString 方法内部,永远不会被逃逸到方法外,因此,虽然这里有锁,也可以被安全地消除。

锁粗化

原则上,编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,这是为了使需要同步的操作数量尽可能变小,让等待的线程尽快拿到锁。大部分情况下这原则是正确的。

但是,如果一系列连续操作都是对同一个对象反复加锁和解锁,甚至加锁操作是在循环体中,频繁地进行互斥同步操作会导致不必要的性能消耗。上述代码中连续的 append 方法就属于此类范围,虚拟机会把加锁同步的范围扩展(粗化)到整个操作序列的外部,就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。

轻量级锁

轻量级锁是相对于传统的锁机制而言的,首先强调的一点是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

要理解轻量级锁,必须从HotSpot虚拟机的对象(对象头部分)的内存布局开始介绍。HotSpot虚拟机的对象头分为两部分:第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄等,官方称为 “Mark Word”,它是实现轻量级锁和偏向锁的关键。第二部分用于存储指向方法区对象类型数据的指针。

在代码进入同步块的时候,如果此同步对象没有被锁定——Mark Word 中锁标记位为 “01”,则虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,如果这个更新动作成功了,那么这个县城就拥有了该对象的锁,并且将对象的 Mark Word 的锁标志位转变为 “00” ,表示对象处于轻量级锁定状态。

如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志位变为 “10” ,后面等待锁的线程也要进入阻塞状态。

上面描述的是加锁过程,解锁过程也是通过CAS 操作来进行的,如果对象的 Mark Word 仍然指向线程的锁记录,那就用 CAS 操作把当前对象的的 Mark Word 和 线程中复制的 Mark Word 替换,如果替换成功,整个同步过程完成。如果替换失败,说明其他线程尝试过获取该锁,那么在释放锁的同时,唤醒被挂起的线程。

轻量锁的依据是“绝大部分锁,在整个同步周期内都是不存在竞争的”,这是经验数据,如果没有竞争,轻量级锁使用CAS 操作避免了使用互斥量的开销;如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作,因此会比传统重量级锁更慢。

偏向锁

如果说轻量级锁是在无竞争情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。

假设当前虚拟机启用了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设置为 “01”,即偏向模式。同时,使用CAS操作把获取到这个锁的线程ID记录在对象的 Mark Word 之中,如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。

当另外一个线程去尝试获取这个锁时,偏向模式就结束。根据锁对象目前是否处于被锁定状态,撤销偏向后恢复到 未锁定(标志位 01) 或 轻量级锁定(标志位 00)的状态,后续的同步操作就如上面介绍的轻量级锁执行。

偏向锁可以提高带有同步但无竞争的程序的性能,它不一定总是对程序运行有利。如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。因此,有时候禁止使用偏向锁反而可以提升性能。

Java语言的“编译期”是一段“不确定”的操作过程,它可能是指一个前端编译器把.java文件转变成 .class 的过程,也可能是指JIT编译器把字节码变成机器码的过程,还能是指 ATO编译器直接把 .java 文件编译成本地代码的过程。最符合大家认知的应该是第一类,在本章中,我们提到的 “编译期” 以及 “编译器” 都指限于第一类过程。

Javac 编译器

前面很多个人觉得只有写编译器才能用得着的内容,先略过。

标注检查:javac的编译过程包含标注检查,标注检查的内容包括诸如变量使用前是否已经被声明,变量与赋值类型是否匹配等。此外,标注检查还有一个重要动作称为常量折叠,如果我们在代码中谢了如下定义:

int a = 1 + 2;

那么在语法树上仍然能看到字面量“1”、“2” 以及操作符 “+”,但是经过常量折叠后,他们将会被折叠为字面量 “3”,由于编译期进行了常量折叠,因此在代码中定义 “a = 1 + 2” 与直接定义 “a = 3” 的cpu指令运算量是一样的,并不会增加额外的哪怕一个cpu指令开销。数据及控制流分析是对程序上下文逻辑的进一步验证,以下举一个关于 final 修饰符的数据及控制流分析的例子:

1
2
3
4
5
6
7
8
9
10
11
12
//方法一有final修饰
public void foo(final int arg){
final int var = 0;
//do something
}


//方法二没有final修饰
public void foo(int arg){
int var = 0;
//do something
}

这两个 foo() 方法中,在代码编写时程序肯定会受到 final 修饰符的影响,不能再改变第一个方法的 arg 和 var 变量的值,但是这两段代码编译出来的Class 文件是没有任何区别的。通过 第六章 的内容可知,局部变量与字段(实例变量、类变量) 是有区别的,前者在常量池中没 CONSTANT_Fieldref_info 符号引用,自然也没有访问标志(Access_Flags)的信息,甚至可能连名称也不会保留下来(取决于编译时的选项),自然在Class文件中不可能知道一个布局变量是不是声明为 final 了,因此,将局部变量声明为 final 对运行是没有影响的,变量的不可变性仅仅由编译器在编译期间保障

Java 语法糖的味道

语法糖可以看做编译器实现的一些 “小把戏” ,这些小把戏不会提供实质性的功能改进,但它可能会使得效率“大提升”,但我们也应该去了解这些“小把戏”背后的真实世界,那样才能利用好它们,而不是被它们迷惑。

泛型与类型擦除

Java中的类型,本质是参数化类型(Parametersized Type)的应用,也就是说所操作的数据类型被指定为一个参数。泛型技术在Java 和 C# 之中的使用方式看似相同,但实现上却有根本性的分期,C# 中的泛型无论在程序源码中、编译后的IL(中间语言)中或是运行期的 CLR 中,都是切实存在的, List 与 List 就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现方式称为类型膨胀,基于这种方法实现的泛型称为真实泛型。

Java 语言中的泛型规则不一样,它们只在源码中存在,在编译后的字节码文件中就已经替换为原来的原声类型了,因此,对于运行期的Java语言来说, ArraList 与 ArrayList 就是同一个类,所以泛型技术实际上是Java的一颗语法糖,这种实现方法称为“类型擦除”,基于这种方法实现的泛型称为 伪泛型。可以通过代码反编译来查看Java泛型实现过程:

1
2
3
4
5
6
7
8
public static void main(String[] args){
Map<String,String> map = new HashMap();
map.put("hello","你好");
map.put("how are you","吃了吗");
Systemt.out.println(map.get("hello"));
Systemt.out.println(map.get("how are you"));
}

把这段代码编译成 Class 文件,再用子界面反编译工具反编译成Java代码,会发现代码变成如下形式:

1
2
3
4
5
6
7
public static void main(String[] args){
Map map = new HashMap();
map.put("hello","你好");
map.put("how are you","吃了吗");
Systemt.out.println((String)map.get("hello"));
Systemt.out.println((String)map.get("how are you"));
}

会发现,反编译回来的 Map 定义都变成了 Map map = new HashMap(),输出的时候,是靠强转实现的,也就是把object转为程序员写的实际类型。Java 的伪泛型招致很多批评的声音,不过这种实现方式在某些情况下丧失了泛型思想应有的一些优雅,比如在类中存在如下两个方法:

1
2
3
4
5
6
7
8
9
public class GenericTypes{
public static void method(List<String> list){
System.out.println("invoke method(List<String> list)");
}

public static void method(List<Integer> list){
System.out.println("invoke method(List<Integer> list)");
}
}

思考一下,这段代码是否正确。也许你已经知道了,这段代码是不能被编译的,因为List 与 List 编译后都被擦除了,变成了一样的原生类型 List ,擦除动作导致这两种方法的特征签名变得一样。初看起来,无法重载的原因找到了,但真的如此吗?其实,泛型擦除成相同的原生类型只是无法重载的原因之一,接着看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class GenericTypes{
public static String method(List<String> list){
System.out.println("invoke method(List<String> list)");
return "";
}

public static int method(List<Integer> list){
System.out.println("invoke method(List<Integer> list)");
return 1;
}

public static void main(String[] args){
method(new ArrayList<String>());
method(new ArrayList<Integer>());
}
}

编译执行发现,不但可以编译还能正常输出结果:

System.out.println(“invoke method(List list)”)
System.out.println(“invoke method(List list)”)

为两个方法添加了不同的返回值之后,方法重载居然成功(注意,仅仅只是在jdk 1.6及以下才能编译通过,高版本是编译不通过的,但在书上是没有这个版本说明的,而我们读者只需要知道有这么个事情就行)了,这是对Java语言中返回值不参与重载选择的基本认知的挑战吗?当然不是的,之所以能够编译成功,是因为两个 method 方法加入了不同的返回值之后,能够共存在同一个 Class 文件了。由于这只是针对低版本的功能,故此处不多解释了。

自动装箱、拆箱与遍历循环

这几个专门拿出来讲只是因为它们是Java语言中使用得最多的语法糖。可以通过以下代码看看这些语法糖在编译后会发生什么变化:

1
2
3
4
5
6
7
8
9
public static void main(String[] args){
List<Integer> list = Arrays.asList(1,2,3,4);
int sum = 0;
for(int i: list){
sum += i;
}

Systemt.out.println(sum);
}

上述代码在自动装箱、拆箱与遍历循环编译后,变成以下样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args){
List<Integer> list = Arrays.asList(new Integer[]{
Integer.valueOf(1),
Integer.valueOf(2),
Integer.valueOf(3),
Integer.valueOf(4),
});
int sum = 0;
for(Iterator localIterator = list.iterator();localIterator.hasNext();){
int i = ((Integer)localIterator.next()).intValue();
sum += i;
}

Systemt.out.println(sum);
}

代码一共包含自动装箱、自动拆箱、遍历循环与变长参数5种语法糖。遍历循环还原成了迭代器的实现,而变长参数则是通过数组的方式转变。语法糖看起来简单,但是也有很多需要注意的地方,如以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;

Long g = 3L;

System.out.println(c==d); //true
System.out.println(e==f); //false Interger 128限制(缓存了 -128~127的对象,超出这个值就重新new,否则就直接取)
System.out.println(c==(a+b)); // true
System.out.println(c.equals(a+b)); //true
System.out.println(g==(a+b)); //true ,这个还真没找到解释的方法
System.out.println(g.equals(a+ b)); //false ,因为 g 是 Long 类型,而 a + b 后是Integer类型,类型都不一样,equals 不会自动处理数据转型

结果和注释都已经写上了,关于Integer的128限制,再来例子说明:

1
2
3
4
5
6
7
8
9
10
Integer i=127;
Integer j =127;
System.out.println(i==j); //true
i=128;
j=128;
System.out.println(i==j); //false

i=new Integer(127);
j=new Integer(127);
System.out.println(i==j); //false

详细解释:jvm在运行时创建了一个缓存区域,并创建了一个integer的数组。这个数组存储了-128至127的值。因此如果integer的值在-128至127之间,则是去缓存里面获取。因此上面的i和j指向的是同一个内存地址。因为128超过了这个缓存区域,因此第二次赋值的时候是重新开辟了两个内存地址。第三次因为使用了new关键字,在java中。new关键字是开辟内存空间。因此第三次赋值是开辟了新的内存空间,此时发现即便i与j都是127,但内存地址不再相同。

包装类的 “==” 运算在不遇到算术运算的情况下不会自动拆箱,并且它们的 equals() 方法不处理数据转型的关系。

条件编译

实战: 插入式注解处理器

概述

Java 程序最初是通过解释器(Interpreter)进行解释执行的,虚拟机发现某个方法或代码块运行特别频繁时,会将它们认定为“热点代码”(Hot Spot Code)。为了提高运行效率,虚拟机会把这些代码编译成本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler, JIT编译器)。

JIT 不是虚拟机必需部分,Java虚拟机规范也没有规定实现,但它却是衡量一款商用虚拟机优秀与否的关键指标,也是最能体现虚拟机技术水平的部分。本章所讲的内容都是基于HotSpot虚拟机。

HotSpot 虚拟机的JIT

首先看几个问题:

  • 为何要使用解释器与编译器并存架构
  • 为何要实现两个不同的JIT
  • 程序何时使用解释器执行?何时使用编译器执行?
  • 哪些程序代码会被编译为本地代码?如何编译为本地代码?
  • 如何从外部观察JIT的编译过程和编译结果?

解释器与编译器

第一个问题,解释器与编译器两者各有优势:当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行;程序运行后,随着时间推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大时,也可以使用解释执行节约内存。同时,解释器可以作为编译器激进优化时的一个“逃生门”。因此,在整个虚拟机架构中,解释器与编译器经常配合工作。

第二个问题,两个不同的JIT一般称为 Client Compiler(也称为 C1 编译) 和 Server Compiler(也称为 C2 编译) 。虚拟机一般会启用分层编译策略,分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次:

  1. 第0层:解释执行,解释器不开启性能监控(Profiling),可触发第1层编译。
  2. 第1层,也称C1编译,将字节码编译成本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
  3. 第2层,也称C2编译,将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

实施分层编译后,C1和C2将会同时工作,许多代码会被多次编译,用C1获取更高的编译速度,用C2获取更好地编译质量,并且解释执行时也无需承担收集性能监控信息的任务。

编译对象与触发条件

第三个问题,上文提到,运行过程中会被即时编译器编译的“热点代码”有两类,即:

  • 被多次调用的方法。
  • 被多次执行的循环体。

解释一下第二点,这是为了解决一个方法只被调用过一次或少量几次,但是方法体内部存在循环次数较多的循环体问题。这样,循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”。

编译过程

默认设置下,无论是方法调用产生的即时编译请求还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍将按照解释方式执行。

编译优化技术

以如下代码清单来说明编译优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static class B {
int value;
final int get() {
return value;
}
}


public void foo() {
y = b.get();

//do something

z = b.get();
sum = y + z;
}

首先说明的是,这些代码优化变换是建立在代码的某种中间表示或机器码之上,绝不是建立在Java源码之上,这里是为了展示方便,使用了Java语言的语法来表示这些优化技术所发挥的作用。

以上的代码已经非常简单了,但仍有许多优化余地,第一步就是方法内联(Method Inlining),内联后的foo函数代码如下:

1
2
3
4
5
6
7
8
public void foo() {
y = b.value;

//do something

z = b.value;
sum = y + z;
}

方法内联的重要性高于其他优化措施,它主要有两个目的:去除方法调用的成本(如建立栈帧等) 以及为其他优化方法建立良好的基础(方法内联膨胀后可以便于在更大范围上采取后续优化手段)。

第二步进行 冗余访问消除(Redundant Loads Elimination),假设上述代码中 do something 刽改变 b.value的值,那就可以将 z = b.value 替换为 z = y ,因为上一句 y = b.value 已经保证 y 与b.value 是一致的,这样就可以不用再去访问对象b的局部变量了。优化后的代码:

1
2
3
4
5
6
7
8
public void foo() {
y = b.value;

//do something

z = y;
sum = y + z;
}

第三步我们进行复写传播(Copy Propagation),因为在这段程序的逻辑中没有必要使用 z 这个变量,它与 y 是完全相等的,因此可以用 y 来替代 z ,复写传播后的代码如下:

1
2
3
4
5
6
7
8
public void foo() {
y = b.value;

//do something

y = y;
sum = y + y;
}

第四步我们进行无用代码消除(Dead Code Elimination)。在上述代码清单中, y = y 是没有意义的,把它擦除后的代码如下:

1
2
3
4
5
6
7
public void foo() {
y = b.value;

//do something

sum = y + y;
}

经过4次优化后,达到的效果一致,但是比原始代码省略了许多语句,执行效率也更高。接下来继续看几项有代表性的优化技术。

公共子表达式消除

这是语言无关的经典优化技术之一,普遍用于各种编译器的经典优化技术,它的含义是:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成了公共子表达式。假设存在如下代码:

int d = (c * b) * 12 + a + (a + b * c);

这段代码交给JIT编译后,它将进行如下优化:因为 c * b 与 b * c 是一样的表达式,并且在计算期间 b 与 c 的值是不可变的,因此表达式可能会被视为:

int d = E * 12 + a + (a + E);

这时候,编译器还可能进行 代数化简(Algebraic Simplification),把表达式变为:

int d = E * 13 + a * 2;

数组边界检查消除

如果有一个数组 foo[] ,在Java语言中访问数组元素 foo[i] 的时候系统将会自动进行上下界的范围检查,即i必须满足 i>= 0 && i < foo.length ,否则抛出异常。 为了安全,数组边界检查是必须的,但是运行期间一次不漏地检查则优点浪费,是可以“商量”的。假如程序中访问一个对象 foo 的某个属性 value,那以Java伪代码表示虚拟机访问的过程如下:

1
2
3
4
5
if(foo != null) {
return foo.value;
}else {
throw new NullPointException();
}

在使用隐式异常优化后,虚拟机会把上述代码变为如下伪代码过程:

1
2
3
4
5
try {
return foo.value;
}catch(segment_fault) {
uncommon_trap();
}

虚拟机会注册一个 segment_fault 信号的异常处理(uncommon_trap),这样当foo不空的时候,对value的访问是不会额外消耗一次对foo判空的开销的。代价就是当 foo 真的为空时,必须转入到异常处理器中恢复并抛出 NullPointException异常,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢,但当 foo 极少为空的时候,隐式异常优化是值得的。

方法内联

方法内联是编译器最重要的优化手段之一,除了消除方法调用的成本之外,它更重要的意义在于为其他优化手段建立良好的基础,如下代码就解释了内联对其他优化手段的意义:

1
2
3
4
5
6
7
8
9
10
public static void foo(Object obj){
if(obj != null){
System.out.println("do something");
}
}

public static void testInline(String[] args){
Object obj = null;
foo(obj);
}

事实上 testInline 方法的内部全部是无用代码,如果不做内联,后续即使进行了无用代码消除优化,也无法发现任何“Dead Code”,因为如果分开来看, foo() 和 testInline() 两个方法的操作都可能是有意义的。因此方法内联的意义不只是把目标方法“复制”到发起调用的方法中避免真实的方法调用。但实际上Java 虚拟机中的内联过程远没有那么简单,如果不是即时编译器做了一些努力,按照经典编译原理的优化理论,大多数的方法都无法进行内联。

逃逸分析

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸;甚至还可能被外部线程访问到,譬如赋值给类变量或者可以在其他线程中访问的实例变量,称为线程逃逸。

如果能证明一个方法不会逃逸到方法或线程外,则可能为这个变量进行一些高效优化:

  • 栈上分配(Stack Allocation):Java 虚拟机中,在Java 堆上分配创建对象的内存空间几乎是Java程序员都清楚的常识了,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作(无论是筛选可回收对象还是回收和整理内存)都需要耗费时间。如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁,减小垃圾收集系统的压力。

  • 同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么变量的读写肯定不会有竞争,对这个变量实施的同步措施就可以被消除。

  • 标量替换(Scalar Replacement):标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及 refrence 类型等)都不能再进一步分解,它们就可以称为标量。相对地,如果一个数据可以继续分解,那它就称作聚合量,Java中的对象就是最典型的聚合量。如果把一个Java 对象拆散,根据程序访问情况将其使用到的成员变量恢复原始类型来访问就叫做标量替换。

    如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写外,还可以为后续进一步优化手段创建条件。

Java 与 C/C++的编译器对比

有兴趣的时候再来补上,略。