0 Android开发艺术探索-目录

2020/01/14 posted in  Android开发艺术探索

0 Android进阶之光-目录

2020/01/14 posted in  Android进阶之光

0 Android进阶解密-目录

2020/01/14 posted in  Android进阶解密

0 Java核心技术-目录

2020/01/15 posted in  Java核心技术

0 深入理解Java虚拟机-目录

01 走近Java
02 Java内存区域与内存溢出异常
03 垃圾收集器与内存分配策略
04 虚拟机性能监控与故障处理工具
05 调优案例分析与实战
06 类文件结构
07 虚拟机类加载机制
08 虚拟机字节码执行引擎
09 类加载及执行子系统的案例与实战
10 早期(编译期)优化
11 晚期(运行期)优化
12 Java内存模型与线程
13 线程安全与锁优化

2019/11/28 posted in  深入理解虚拟机

00 学习攻略 | 如何才能学好并发编程?

并发编程领域可以抽象成三个核心问题:分工、同步和互斥

1. 分工

所谓分工,即拆分任务,例如不同的线程做不同的事情。

Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是一种分工方法。除此之外,并发编程领域还总结了一些设计模式,基本上都是和分工方法相关的,例如生产者 - 消费者、Thread-Per-Message、Worker Thread 模式等都是用来指导你如何分工的。

2. 同步

一个线程执行完了一个任务,如何通知执行后续任务的线程开工而已。

协作一般是和分工相关的。Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是分工方法,但同时也能解决线程协作的问题。除此之外,Java SDK 里提供的 CountDownLatch、CyclicBarrier、Phaser、Exchanger 也都是用来解决线程协作问题的。

当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行。

在 Java 并发编程领域,解决协作问题的核心技术是管程管程是解决并发问题的万能钥匙。

3. 互斥

分工、同步主要强调的是性能,但并发程序里还有一部分是关于正确性的,用专业术语叫“线程安全”。并发程序里,当多个线程同时访问同一个共享变量的时候,结果是不确定的。不确定,则意味着可能正确,也可能错误,事先是不知道的。而导致不确定的主要源头是可见性问题、有序性问题和原子性问题,为了解决这三个问题,Java 语言引入了内存模型,内存模型提供了一系列的规则,利用这些规则,我们可以避免可见性问题、有序性问题,但是还不足以完全解决线程安全问题。解决线程安全问题的核心方案还是互斥

所谓互斥,指的是同一时刻,只允许一个线程访问共享变量。

实现互斥的核心技术就是,Java 语言里 synchronized、SDK 里的各种 Lock 都能解决互斥问题。虽说锁解决了安全性问题,但同时也带来了性能问题,那如何保证安全性的同时又尽量提高性能呢?可以分场景优化,Java SDK 里提供的 ReadWriteLock、StampedLock 就可以优化读多写少场景下锁的性能。还可以使用无锁的数据结构,例如 Java SDK 里提供的原子类都是基于无锁技术实现的。

除此之外,还有一些其他的方案,原理是不共享变量或者变量只允许读。这方面,Java 提供了 Thread Local 和 final 关键字,还有一种 Copy-on-write 的模式。

使用锁除了要注意性能问题外,还需要注意死锁问题。

2019/12/05 posted in  极客-Java并发实战

00 开篇词 | 为什么我们要学习Java虚拟机?

2019/12/14 posted in  极客-深入拆解Java虚拟机

00 深入拆解Java虚拟机

开篇词 (1讲)

开篇词 | 为什么我们要学习Java虚拟机?

模块一:Java虚拟机基本原理 (12讲)

01 | Java代码是怎么运行的?

02 | Java的基本类型

03 | Java虚拟机是如何加载Java类的?

04 | JVM是如何执行方法调用的?(上)

05 | JVM是如何执行方法调用的?(下)

06 | JVM是如何处理异常的?

07 | JVM是如何实现反射的?

08 | JVM是怎么实现invokedynamic的?(上)

09 | JVM是怎么实现invokedynamic的?(下)

10 | Java对象的内存布局

11 | 垃圾回收(上)

12 | 垃圾回收(下)

模块二:高效编译 (12讲)

【工具篇】 常用工具介绍

13 | Java内存模型

14 | Java虚拟机是怎么实现synchronized的?

15 | Java语法糖与Java编译器

16 | 即时编译(上)

17 | 即时编译(下)

18 | 即时编译器的中间表达形式

19 | Java字节码(基础篇)

20 | 方法内联(上)

21 | 方法内联(下)

22 | HotSpot虚拟机的intrinsic

23 | 逃逸分析

模块三:代码优化 (10讲)

24 | 字段访问相关优化

25 | 循环优化

26 | 向量化

27 | 注解处理器

28 | 基准测试框架JMH(上)

29 | 基准测试框架JMH(下)

30 | Java虚拟机的监控及诊断工具(命令行篇)

31 | Java虚拟机的监控及诊断工具(GUI篇)

32 | JNI的运行机制

33 | Java Agent与字节码注入

模块四:黑科技 (3讲)

34 | Graal:用Java编译Java

35 | Truffle:语言实现框架

36 | SubstrateVM:AOT编译框架

尾声 (1讲)

尾声 | 道阻且长,努力加餐

2019/12/14 posted in  极客-深入拆解Java虚拟机

00【工具篇】 常用工具介绍

2019/12/14 posted in  极客-深入拆解Java虚拟机

01 Activity的生命周期和启动模式

  1. 典型情况:在有用户参与的情况下,Activity所经过的生命周期的改变
  2. 异常情况:Activity被系统回收或者由于当前设备的Configuration发生改变从而导致Activity被销毁重建。

1.1 Activity的生命周期全面分析 / 1

1.1.1 典型情况下的生命周期分析 / 2

  • onStart和onResume,onPause和onStop的区别?
    • onStart和onStop是从Activity是否可见来回调的
    • onResume和onPause是从Activity是否位于前台这个角度来回调的。
  • 当前Activity为A,打开ActivityB,A的onPause会先于B的onResume执行。所以不能在onPause中执行重量级的操作,因为onPause方法执行完新的Activity的onResume才可以执行。

1.1.2 异常情况下的生命周期分析 / 8

情况1:资源相关的系统配置发生改变导致Activity被杀死并重新创建

  1. Activity异常状态终止时,系统会调用onSaveInstanceState来保存Activity的状态
  2. 当Activity被重新创建时,系统会调用onRestoreInstanceState,同时把被销毁时保存的Bundle对象传递给方法。
  3. 系统默认恢复的:文本框的输入、ListView的滚动位置
  4. 保存和恢复View的层次结构:委托上层保存数据 Activity > Window > ViewGroup(DecorView) 委托思想 上层委托下层,父容器委托子元素处理事情。

情况2:内存资源不足导致低优先级的Activity被杀死

  1. Activity按优先级从高到低:
    1. 前台Activity:正在和用户交互的Activity
    2. 可见但非前台Activity:有弹窗的Activity
    3. 后台Activity:已经被暂停的Activity
  2. 系统配置发生改变时不重建Activity的方法:
    1. android:configChange="orientation"

1.2 Activity的启动模式 / 16

1.2.1 Activity的LaunchMode / 16

四种启动模式

  1. standard:标准模式
  2. singtop:栈顶复用模式,位于栈顶时不会重复创建
  3. singleTask:栈内复用模式,实例以上Activity全部出栈,把实例调到栈顶。
  4. singleInstance:单实例模式,创建实例时会创建新的任务栈

TaskAffinity 任务相关性

  1. taskAffinity:标识一个Activity所需要的任务栈的名字
  2. 默认Activity的栈名为包名
  3. 主要和singTask或allowTaskReparenting属性配对使用
    1. taskAffinity+singleTask,待启动的Activity会运行在名字和TaskAffinity相同的任务栈中
    2. taskAffinity+allowTaskReparenting,应用A启动应用B的某个Activity后,此Activity的allowTaskReparenting为true时,此Activity会直接从应用A的任务栈转移到B的任务栈中。

1.2.2 Activity的Flags / 27

Flags影响启动模式、运行状态等

  1. FLAG_ACTIVITY_NEW_TASK
  2. FLAG_ACTIVITY_SINGLE_TOP
  3. FLAG_ACTIVITY_CLEAR_TOP
  4. FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS

1.3 IntentFilter的匹配规则 / 28

Activity的隐式调用需要Intent能够匹配目标组件的IntentFilter中的过滤信息,如果不匹配将无法启动目标Activity。

匹配规则:

  1. 一个intent-filter中可以有多个cation、category、data
  2. 一个Intent同时匹配action、category、data 才算完全匹配,只有完全匹配才能成功启动目标Activity。
  3. 一个Activity 中可以有多个intent-filter,只要能匹配任何一组intent-filter即可成功启动对应的Activity。

  • action的匹配规则
    • 要求Intent中的action存在,且必须和过滤规则中的其中一个action相同。
  • category的匹配规则
    • 要求Intent 中如果含有category,那么所有的category都必须和过滤规则中的其中一个category相同。
  • data的匹配规则
    • 如果过滤规则中定义了data, 那么Intent 中必须也要定义可匹配的data。
    • data由两部分组成,mineType和URI。
      • mimeType指媒体类型,比如image/jpeg、audio/mpeg4-generic和video/*等,可以表示图片、文本、视频等不同的媒体格式。
      • URI包含的数据就比较多了,URI结构:scheme://host:port/path|pathPrefix|pathPattern
      • Scheme、Host、Port、Path等
  • 隐式启动一个Activity时,可能出现错误,判断是否有Activity符合匹配规则:
    • PackageManager.resolveActivity或Intent.resolveActivity,返回最佳匹配的Activity信息
    • queryIntentActivities,返回所有成功的Activity信息
2019/02/26 posted in  Android开发艺术探索

01 Android新特性

2019/03/27 posted in  Android进阶之光

01 Android系统架构

1.1 Android系统架构

1.2 Android系统源码目录

1.2.1 整体结构

1.2.2 应用层部分

1.2.3 应用框架层部分

1.2.4 C/C++程序库部分

1.3 源码阅读

1.3.1 在线阅读

1.3.2 使用Source Insight

1.4 本章小结

2019/03/27 posted in  Android进阶解密

01 | Java代码是怎么运行的?

  • 为什么 Java 要在虚拟机里运行?
  • Java 虚拟机具体是怎样运行 Java 字节码的?
  • Java 虚拟机的运行效率究竟是怎么样的?

为什么 Java 要在虚拟机里运行?

  1. Java是一种高级语言,不能直接运行在硬件,需要转换
  2. 设计一个面向Java语言的虚拟机,通过编译器将 Java 程序转换成该虚拟机所能识别的指令序列。
  3. 在现有平台(Windows Linux)上实现Java虚拟机,Java程序便可以在不同平台上的虚拟机实现里运行。

Java 虚拟机具体是怎样运行 Java 字节码的?

  • 从虚拟机视角来看
    1. 执行 Java 代码首先需要将它编译而成的 class 文件加载到 Java 虚拟机中。加载后的 Java 类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法区内的代码。
    2. 在运行过程中,每当调用进入一个 Java 方法,Java 虚拟机会在当前线程的 Java 方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且 Java 虚拟机不要求栈帧在内存空间里连续分布。
    3. 当退出当前执行的方法时,不管是正常返回还是异常返回,Java 虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。
  • 从硬件视角来看,Java 字节码无法直接执行。因此,Java 虚拟机需要将字节码翻译成机器码。
    1. 第一种是解释执行,即逐条将字节码翻译成机器码并执行;
    2. 第二种是即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。

Java 虚拟机的运行效率究竟是怎么样的?

  1. 即时编译建立在程序符合二八定律的假设上,也就是百分之二十的代码占据了百分之八十的计算资源。
  2. 即时编译,对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;
  3. 另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。

为了满足不同用户场景的需要,HotSpot 内置了多个即时编译器:

  1. C1 又叫做 Client 编译器,面向的是对启动性能有要求的客户端 GUI 程序,采用的优化手段相对简单,因此编译时间较短。
  2. C2 又叫做 Server 编译器,面向的是对峰值性能有要求的服务器端程序,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高。
  3. Graal 是 Java 10 正式引入的实验性即时编译器。

从 Java 7 开始,HotSpot 默认采用分层编译的方式:热点方法首先会被 C1 编译,而后热点方法中的热点会进一步被 C2 编译。

总结与实践

今天我简单介绍了 Java 代码为何在虚拟机中运行,以及如何在虚拟机中运行。

之所以要在虚拟机中运行,是因为它提供了可移植性。一旦 Java 代码被编译为 Java 字节码,便可以在不同平台上的 Java 虚拟机实现上运行。此外,虚拟机还提供了一个代码托管的环境,代替我们处理部分冗长而且容易出错的事务,例如内存管理。

Java 虚拟机将运行时内存区域划分为五个部分,分别为方法区、堆、PC 寄存器、Java 方法栈和本地方法栈。Java 程序编译而成的 class 文件,需要先加载至方法区中,方能在 Java 虚拟机中运行。

为了提高运行效率,标准 JDK 中的 HotSpot 虚拟机采用的是一种混合执行的策略。它会解释执行 Java 字节码,然后会将其中反复执行的热点代码,以方法为单位进行即时编译,翻译成机器码后直接运行在底层硬件之上。

HotSpot 装载了多个不同的即时编译器,以便在编译时间和生成代码的执行效率之间做取舍。

2019/12/14 posted in  极客-深入拆解Java虚拟机

01 | 可见性、原子性和有序性问题:并发编程Bug的源头

  • 并发程序幕后的故事
  • 源头之一:缓存导致的可见性问题
  • 源头之二:线程切换带来的原子性问题
  • 源头之三:编译优化带来的有序性问题

并发程序幕后的故事

CPU、内存、I/O 设备都在不断迭代,但有一个核心矛盾一直存在,就是这三者的速度差异

为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  1. CPU 增加了缓存,以均衡与内存的速度差异;
  2. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

现在我们几乎所有的程序都默默地享受着这些成果,但是天下没有免费的午餐,并发程序很多诡异问题的根源也在这里。

源头之一:缓存导致的可见性问题

在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性

多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。

源头之二:线程切换带来的原子性问题

由于 IO 太慢,早期的操作系统就发明了多进程,即便在单核的 CPU 上我们也可以一边听着歌,一边写 Bug,这个就是多进程的功劳。

高级语言里一条语句往往需要多条 CPU 指令完成,例如count += 1,至少需要三条 CPU 指令:

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行 +1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完。

我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。因此,很多时候我们需要在高级语言层面保证操作的原子性。

源头之三:编译优化带来的有序性问题

编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”

在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

  1. 分配一块内存 M;
  2. 将 M 的地址赋值给 instance 变量;
  3. 最后在内存 M 上初始化 Singleton 对象。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

总结

只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。

在介绍可见性、原子性、有序性的时候,特意提到缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题,其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。

课后思考

常听人说,在 32 位的机器上对 long 型变量进行加减操作存在并发隐患,到底是不是这样呢?

答:long类型64位,所以在32位的机器上,对long类型的数据操作通常需要多条指令组合出来,无法保证原子性,所以并发的时候会出问题。

2019/12/05 posted in  极客-Java并发实战

01 为什么要学习数据结构和算法?

2019/04/01 posted in  极客-数据结构与算法之美

01 走近Java

2019/11/28 posted in  深入理解虚拟机

01 面向对象的六大原则

SOLID

名称 缩写 说明
单一职责原则 SRP
Single Responsibility Principle
就一个类而言,应该只有一个引起它变化的原因。
开闭原则 OCP
Open Closed Principle
一个软件实体应当对扩展开放,对修改关闭。
里式替换原则 LSP
Liskov Substitution Principle
所有引用基类(父类)的地方必须能透明地使用其子类的对象。
接口隔离原则 ISP
Interface Segregation Principle
客户端不应该依赖那些它不需要的接口。
依赖倒置原则 DIP
Dependency Inversion Principle
抽象不应该依赖于细节,细节应当依赖于抽象。
迪米特原则 LoD
Law of Demeter
一个软件实体应当尽可能少地与其他实体发生相互作用。
(合成复用原则) CRP
Composite Reuse Principle
尽量使用对象组合,而不是继承来达到复用的目的。

开闭原则是目标,里氏代换原则是基础,依赖倒转原则是
手段。

23种设计模式

类型模式 名称 英文
创建型模式 单例模式 Singleton Pattern
简单工厂模式 Simple Factory Pattern
工厂方法模式 Factory Method Pattern
抽象工厂模式 Abstract Factory Pattern
原型模式 Prototype Pattern
建造者模式 Builder Pattern
结构型模式 适配器模式 Adapter Pattern
桥接模式 Bridge Pattern
组合模式 Composite Pattern
装饰模式 Decorator Pattern
外观模式 Façade Pattern
享元模式 Flyweight Pattern
代理模式 Proxy Pattern
行为型模式 职责链模式 Chain of Responsibility Pattern
命令模式 Command Pattern
解释器模式 Interpreter Pattern
迭代器模式 Iterator Pattern
中介者模式 Mediator Pattern
备忘录模式 Memento Pattern
观察者模式 Observer Pattern
状态模式 State Pattern
策略模式 Strategy Pattern
模板方法模式 Template Method Pattern
访问者模式 Visitor Pattern
2019/11/01 posted in  菜鸟-设计模式

01Java程序设计概述

Java程序设计平台

Java“白皮书”的关键术语

Java applet与Internet

Java发展简史

关于Java的常见误解

2019/03/28 posted in  Java核心技术

02 Android系统启动

2.1 init进程启动过程

2.1.1 引入init进程

Android系统启动流程的前几步:

  1. 启动电源以及系统启动
  2. 引导程序Bootloader
  3. linux内核启动
  4. init进程启动

2.1.2 init进程的入口函数

2.1.3 解析init.rc

2.1.4 解析Service类型语句

2.1.5 init启动Zygote

2.1.6 属性服务

2.1.7 init进程启动总结

init进程主要做了三件事:

  1. 创建一些文件夹并挂载设备
  2. 初始化和启动属性服务
  3. 解析init.rc配置文件并启动zygote进程

2.2 Zygote进程启动过程

2.2.1 Zygote概述

在Android系统中,DVM(Dalvik虚拟机)、应用程序进程以及运行系统的关键服务的SystemServer进程都是由Zygote进程来创建的,我们也将它称为孵化器。它通过fock(复制进程)的形式来创建应用程序进程和SystemServer进程,由于Zygote进程在启动时会创建DVM,因此通过fock而创建的应用程序进程和SystemServer进程可以在内部获取一个DVM的实例拷贝。

2.2.2 Zygote启动脚本

2.2.3 Zygote进程启动过程介绍

2.2.4 Zygote进程启动总结

Zygote进程共做了如下几件事:

  1. 创建AppRuntime并调用其start方法,启动Zygote进程。
  2. 创建JVM并为JVM注册JNI方法。
  3. 通过JNI调用ZygoteInit的main函数进入Zygote的Java框架层。
  4. 通过registerZygoteSocket函数创建服务端Socket,并通过runSelectLoop函数等待ActivityManagerService的请求来创建新的应用程序进程。
  5. 启动SystemServer进程。

2.3 SystemServer处理过程

2.3.1 Zygote处理SystemServer进程

2.3.2 解析SystemServer进程

2.3.3 SystemServer进程总结

SystemServer进程被创建后,主要做了如下工作:

  1. 启动Binder线程池,这样就可以与其他进程进行通信。
  2. 创建SystemServiceManager,其用于对系统的服务进行创建、启动和生命周期管理。
  3. 启动各种系统服务。

2.4 Launcher启动过程

2.4.1 Launcher概述

系统启动的最后一步是启动一个应用程序用来显示系统中已经安装的应用程序,这个应用程序就叫作Launcher。Launcher在启动过程中会请求PackageManagerService返回系统中已经安装的应用程序的信息,并将这些信息封装成一一个快捷图标列表显示在系统屏幕上,这样用户可以通过点击这些快捷图标来启动相应的应用程序。
通俗来讲Launcher就是Android系统的桌面,它的作用主要有以下两点:

  1. 作为Android系统的启动器,用于启动应用程序。
  2. 作为Android系统的桌面,用于显示和管理应用程序的快捷图标或者其他桌面组件。

2.4.2 Launcher启动过程介绍

2.4.3 Launcher中应用图标显示过程

2.5 Android系统启动流程

  1. 启动电源以及系统启动
    当电源按下时引导芯片代码开始从预定义的地方(固化在ROM)开始执行。加载引导程序Bootloader到RAM,然后执行。
  2. 引导程序BootLoader
    引导程序BootLoader是在Android操作系统开始运行前的一个小程序,它的主要作用是把系统OS拉起来并运行。
  3. Linux内核启动
    内核启动时,设置缓存、被保护存储器、计划列表、加载驱动。当内核完成系统设置,它首先在系统文件中寻找init.rc文件,并启动init进程。
  4. init进程启动
    初始化和启动属性服务,并且启动Zygote进程。
  5. Zygote进程启动
    创建JavaVM并为JavaVM注册JNI,创建服务端Socket,启动SystemServer进程。
  6. SystemServer进程启动
    启动Binder线程池和SystemServiceManager,并且启动各种系统服务。
  7. Launcher启动

2.6 本章小结

2019/03/27 posted in  Android进阶解密

02 IPC机制

2.1 Android IPC简介

  1. IPC Inter-Process Communication,含义为进程间通信或者跨进程通信,是指两个进程之间进行数据交换的过程。
    • 进程:一个执行单元,PC和移动设备上指一个程序或应用
    • 线程:CPU调度的最小单元,一种有限的系统资源
  2. 为什么需要多进程?
    1. 一个应用因为某些原因需要采用多进程模式来实现。比如某些模块运行在单独进程中,或者为了更多的内存空间
    2. 向其他应用获取数据

2.2 Android中的多进程模式

2.2.1 开启多进程模式

指定android:process属性开启多进程模式:

  • ":":当前应用的私有进程,其他应用的组件不可以和它跑在一个进程中;
  • ".":全局进程,其他应用通过ShareUID方式可以和他跑在同一个进程中。

Android系统会为每个应用分配一个唯一的UID,具有相同UID的应用才能共享数据。两个应用通过ShareUID跑在同一个进程,需要这两个应用有相同的ShareUID并且签名相同才可以。在这种情况下,它们可以互相访问对方的私有数据,比如data目录,组件信息等,不管他们是否跑在同一个进程中。如果在同一个进程中,还可以共享内存数据。

2.2.2 多进程模式的运行机制

Android为每一个应用分配了独立的虚拟机,不同的虚拟机在内存分配上有不同的地址空间。

使用多进程会导致的问题:

  1. 静态成员和单例模式完全失效
  2. 线程同步机制完全失效
  3. SharedPreferences的可靠性下降
  4. Application会创建多次

2.3 IPC基础概念介绍

序列化:将对象的状态信息转换为可以存储或传输的形式的过程。

2.3.1 Serializable接口

  • serialVersionUID
    • 辅助序列化和反序列化
    • serialVersionUID相同才能反序列化成功
  • 两种变量不参与序列化:
    • 静态成员属于类,不属于对象
    • transient关键字标记的成员变量
  • 使用ObjectOutputStreamObjectInputStream进行对象的序列化和反序列化。
  • 重写writeObject()readObject()方法可以重写序列化和反序列化过程。
  • 反序列化失败的几种情况:未指定serialVersionUID;成员变量的数量、类型结构等变化时;非常规性改版:类名变化。

2.3.2 Parcelable接口

Parcelable主页方法:

  • writeToParcel:序列化
  • CREATOR:read方法,反序列化
  • describeContents:内容描述

Serializable和Parcelable:

  • 两者都可以实现序列化并可用于intent间的数据传递
  • Serializable使用简单,但是开销很大,推荐使用在存储设备或者网络传输;
  • Parcelable效率很高,主要用在内存序列化上。

2.3.3 Binder

Binder的理解:

  • 直观来说,Binder 是Android中的一个类,它实现了IBinder接口。
  • 从IPC角度来说,Binder是Android中的一种跨进程通信方式:
    • 从Android Framework角度来说,Binder是ServiceManager连接各种Manager (ActivityManager、WindowManager, 等等)和相应ManagerService的桥梁;
    • 从Android应用层来说,Binder 是客户端和服务端进行通信的媒介,当bindService的时候,服务端会返回一个包含了服务端业务调用的Binder 对象,通过这个Binder对象,客户端就可以获取服务端提供的服务或者数据,这里的服务包括普通服务和基于AIDL的服务。

Android开发中, Binder主要用在Service中,包括AIDL和Messenger。其中普通的Service的Binder不涉及进程间通讯;而Messenger的底层其实就是AIDL。

  1. 新建一个AIDL示例,SDK会自动为我们生产AIDL所对应的Binder类。创建Book.javaBook.aidlIBookManager.aidl,Build后生成IBookManager.java

  • Book.java

    public class Book implements Parcelable {
        public int bookId;
    public String bookName;
    //...
    }
  • Book.aidl

    package com.wz.testaidl;
    import com.wz.testaidl.Book;
    parcelable Book ;
  • IBookManager.aidl

    package com.wz.testaidl;
    import com.wz.testaidl.Book;
    interface IBookManager{
    List<Book> getBookList();
    void addBook(in Book book);
    }

AIDL自动生成的java文件方法说明:

  • DESCRIPTOR:Binder的唯一标识,一般用当前Binder的类名表示。
  • asInterface:将服务端的Binder对象转换成客户端所需要的AIDL接口类型的对象;
    • 客户端和服务端位于相同进程,那么此方法返回的就是服务端Stub对象本身;
    • 否则返回系统封装后的Stub.proxy对象。
  • asBinder:用于返回当前的Binder对象
  • onTransact:运行在服务端的Binder线程池中,当客户端发起跨进程通讯时,远程请求会通过系统底层封装交由此方法处理。
  • Proxy#[Method]:代理类中的接口方法。内部实现:
    1. 首先创建该方法所需要的输入型参数Parcel对象_data和输出型参数Parcel对象_reply;
    2. 然后把参数写入_data中(如果有参数的话);
    3. 接着调用transact方法来发起RPC(远程过程调用)请求,同时当前线程挂起;
    4. 然后服务端的onTransace方法会被调用直到RPC过程返回后,当前线程继续执行,并从_reply中取出RPC过程的返回结果;
    5. 最后返回_reply中的数据。

首先,当客户端发起远程请求时,由于当前线程会被挂起直至服务端进程返回数据,所以如果一个远程方法是很耗时的,那么不能在UI线程中发起此远程请求;其它,由于服务端的Binder方法运行在Binder的线程池中,所以Binder方法不管是否耗时都应该采用同步的方式去实现,因为它已经运行在一个线程中了。

Binder死亡通知
Binder的两个重要方法linkToDeathunlinkToDeath

  • 通过linkToDeath可以给Binder设置一个死亡代理,当Binder死亡时,我们就会收到通知,然后就可以重新发起连接请求。
  • 声明一个DeathRecipient对象,DeathRecipient是一个接口,其内部只有一个方法binderDied,实现这个方法后就可以在Binder死亡的时候收到通知了。

    private IBinder.DeathRecipient mDeathRecipient = new IBinder.DeathRecipient(){
        @override
    public void binderDied(){
    if(mBookManager==null){
    return;
    }
    mBookManager.asBinder().unlinkToDeath(mDeathRecipient,0);
    mBookManager=null;
    //TODO:重新绑定远程Service
    }
    }
  • 在客户端绑定远程服务成功后,给binder设置死亡代理:

    mService = IMessageBoxManager.Stub.asInterface(binder);
    binder.linkToDeath(mDeathRecipient,0);
    
  • 另外,通过Binder的isBinderAlive方法,也可以判断Binder是否死亡。

  • binderDiedonServiceDisconnected区别:

    • binderDied在客户端的Binder线程池中被回调
    • onServiceDisconnected在UI线程中被回调

2.4 Android中的IPC方式

2.4.1 使用Bundle

我们知道,四大组件中的三大组件( Activity、Service、 Receiver) 都是支持在Intent中传递Bundle数据的,由于Bundle实现了Parcelable 接口,所以它可以方便地在不同的进程间传输。基于这一点,当我们在一个进程中启动了另一个进程的Activity、 Service 和Receiver,我们就可以在Bundle 中附加我们需要传输给远程进程的信息并通过Intent 发送出去。当然,我们传输的数据必须能够被序列化,比如基本类型、实现了Parcellable 接口的对象、实现了Serializable 接口的对象以及一些Android支持的特殊对象,具体内容可以看Bundle这个类,就可以看到所有它支持的类型。Bundle不支持的类型我们无法通过它在进程间传递数据。

除了直接传递数据这种典型的使用场景,它还有一种特殊的使用场景。比如A进程正在进行一个计算,计算完成后它要启动B进程的一个组件并把计算结果传递给B进程,可是遗憾的是这个计算结果不支持放入Bundle中,因此无法通过Intent来传输,这个时候如果我们用其他IPC方式就会略显复杂。可以考虑如下方式:我们通过Intent启动进程B的一个Service组件(比如IntentService), 让Service在后台进行计算,计算完毕后再启动B进程中真正要启动的目标组件,由于Service也运行在B进程中,所以目标组件就可以直接获取计算结果,这样一来就轻松解决了跨进程的问题。这种方式的核心思想在于将原本需要在A进程的计算任务转移到B进程的后台Service中去执行,这样就成功地避免了进程间通信问题,而且只用了很小的代价。

2.4.2 使用文件共享

通过文件共享这种方式来共享数据对文件格式是没有具体要求的,比如可以是文本文件,也可以是XML文件,只要读/写双方约定数据格式即可。通过文件共享的方式也是有局限性的,比如并发读/写的问题,如果并发读/写,那么我们读出的内容就有可能不是最新的,如果是并发写的话那就更严重了。因此我们要尽量避免并发写这种情况的发生或者考虑使用线程同步来限制多个线程的写操作。文件共享方式适合在对数据同步要求不高的进程之间进行通信,并且要妥善处理并发读/写的问题。

当然,SharedPreferences 是个特例,SharedPreferences 是Android中提供的轻量级存储方案,它通过键值对的方式来存储数据,在底层实现上它采用XML文件来存储键值对,每个应用的SharedPreferences文件都可以在当前包所在的data目录下查看到。一般来说, 它的目录位于/data/data/package name/shared_ prefs 目录下,其中package name表示的是当前应用的包名。从本质上来说,SharedPreferences 也属于文件的一种,但是由于系统对它的读/写有一定的缓存策略,即在内存中会有一份SharedPreferences文件的缓存,因此在多进程模式下,系统对它的读/写就变得不可靠,当面对高并发的读/写访问,Sharedpreferences有很大几率会丢失数据,因此,不建议在进程间通信中使用SharedPreferences

2.4.3 使用Messenger

  1. 构建服务端Service,运行在独立进程中:

    public class MessengerService extends Service {
        private static final String TAG = "MessengerService";
    @Override
    public IBinder onBind(Intent intent) {
    return mMessenger.getBinder();
    }
    private final Messenger mMessenger = new Messenger(new MessengerHandler());
    private static class MessengerHandler extends Handler {
    @Override
    public void handleMessage(@NonNull Message msg) {
    Log.e(TAG, "server receive msg: " + msg.what);
    final Messenger replyTo = msg.replyTo;
    final Message replyMsg = Message.obtain();
    replyMsg.what = 999;
    try {
    replyTo.send(replyMsg);
    } catch (RemoteException e) {
    e.printStackTrace();
    }
    }
    }
    }
    // AndroidManifext.xml
    <service android:name=".MessengerService"
    android:process=":remote"/>
  2. 客户端:

    1. 通过绑定服务端返回的binder创建Messenger对象,并通过这个Messenger对象向服务端发送消息。
    2. 服务端给客户端回复消息:使用Message的replyTo参数。
    public class MainActivity extends AppCompatActivity {
        private static final String TAG = "MainActivity";
    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    final Intent intent = new Intent(this, MessengerService.class);
    bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
    }
    private ServiceConnection serviceConnection = new ServiceConnection() {
    @Override
    public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
    Log.e(TAG, "onServiceConnected: ");
    Messenger mServerMessenger = new Messenger(iBinder);
    final Message msg = Message.obtain();
    msg.what = 666;
    //赋值replyTo,服务端才可以回复消息
    msg.replyTo = mClientMessenger;
    try {
    mServerMessenger.send(msg);
    } catch (RemoteException e) {
    e.printStackTrace();
    }
    }
    @Override
    public void onServiceDisconnected(ComponentName componentName) {
    }
    };
    private Messenger mClientMessenger = new Messenger(new MessengerHandler());
    private static class MessengerHandler extends Handler {
    @Override
    public void handleMessage(@NonNull Message msg) {
    Log.e(TAG, "client receive msg: " + msg.what);
    }
    }
    @Override
    protected void onDestroy() {
    unbindService(serviceConnection);
    super.onDestroy();
    }
    }

    流程图如下:

总结:

  • Messenger 是以串行的方式处理客户端发来的消息,如果大量的消息同时发送到服务端,服务端仍然只能一个个处理,如果有大量的并发请求,那么用Messenger就不太合适了。
  • Messenger的作用主要是为了传递消息,很多时候我们可能需要跨进程调用服务端的方法,这种情形用Messenger就无法做到了,但是我们可以使用AIDL来实现跨进程的方法调用。

2.4.4 使用AIDL

使用AIDL来进行进程间通信的流程,分为服务端和客户端两个方面。

  1. 服务端:

    1. 创建一个AIDL文件,将暴露给客户端的接口在这个AIDL文件中声明(见2.3.3)
    2. 创建一个Service用来监听客户端的连接请求,在Service中实现AIDL接口。
    public class BookMangerService extends Service {
        private CopyOnWriteArrayList<Book> mBookList = new CopyOnWriteArrayList<>();
    @Override
    public IBinder onBind(Intent intent) {
    return mBinder;
    }
    private IBinder mBinder = new IBookManager.Stub(){
    @Override
    public List<Book> getBookList() {
    return mBookList;
    }
    @Override
    public void addBook(Book book) {
    mBookList.add(book);
    }
    };
    }
    <service android:name=".BookMangerService"
        android:process=":remote"/>
    
  2. 客户端

    1. 首先需要绑定服务端的Service
    2. 绑定成功后,将服务端返回的Binder对象转成AIDL接口所属的类型,接着就可以调用AIDL中的方法了。
    public class MainActivity extends AppCompatActivity {
        private static final String TAG = "MainActivity";
    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    final Intent intent = new Intent(this, BookMangerService.class);
    bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
    }
    private ServiceConnection serviceConnection = new ServiceConnection() {
    @Override
    public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
    final IBookManager bookManager = IBookManager.Stub.asInterface(iBinder);
    try {
    Log.e(TAG, "bookList.size(): " + bookManager.getBookList().size());
    bookManager.addBook(new Book());
    bookManager.addBook(new Book());
    Log.e(TAG, "bookList.size(): " + bookManager.getBookList().size());
    } catch (RemoteException e) {
    e.printStackTrace();
    }
    }
    @Override
    public void onServiceDisconnected(ComponentName componentName) {
    }
    };
    @Override
    protected void onDestroy() {
    unbindService(serviceConnection);
    super.onDestroy();
    }
    }

RemoteCallbackList

在解注册的过程中,服务端无法找到之前注册的那个listener,因为Binder会把客户端传递过来的对象重新转化并生成一个新的对象。

RemoteCallbackList是系统专门提供的用于删除跨进程listener的接口。Remote一CallbackList是一个泛型,支持管理任意的AIDL接口。内部有一个Map结构专门用来保存所有的AIDL回调,这个Map的key是IBinder类型,value 是Callback类型,如下所示。

public class RemoteCallbackList<E extends IInterface>{
    ArrayMap<IBinder, Callback> mCallbacks = new ArrayMap<IBinder, Callback>() ;
}

其中Callback中封装了真正的远程listener。 当客户端注册listener 的时候,它会把这个listener的信息存入mCallbacks中,其中key和value分别通过下面的方式获得:

IBinder key = listener.asBinder();
Callback value = new Callback(listener,cookie);

当客户端解注册的时候,只要遍历服务端所有的listener,找出那个和解注册listener具有相同Binder对象的服务端listener并把它删除就可以了。当客户端进程终止后,RemoteCallbackList能够自动移除客户端所注册的listener。RemoteCallbackList内部自动实现了线程同步的功能,所以使用它来注册和解注册时,不需要做额外的线程同步工作。

使用RemoteCallbackList,有一点需要注意。我们无法像操作List一样去操作它,尽管它的名字中也带个List,但是它并不是一个List。遍历RemoteCallbackList,必须要按照下面的方式进行,其中beginBroadcastfinishBroadcast必须配对使用,哪怕我们仅仅是想要获取RemoteCallbackList的元素个数。

final int N = mListenerList.beginBroadcast();
for(int i=0;i<N;i++){   
    IOnNewBookArrivedListener l = mListenerList.getBroadcastItem(i);
    if(l!=null){
        //TODO
    }
}
mListenerList.finishBroadcast();

如何在AIDL中使用权限验证功能?

  1. 第一种方法:可以在onBind中进行验证,验证不通过就直接返回null。然后在AndroidMenifest中声明所需的权限。

    public IBinder onBind(Intent intent){
        int check = checkCallingOrSelfPermission("xx.xx.xx");
    if(check==PackageManager.PERMISSION_DENIED){
    return null;
    }
    return mBinder;
    }
    <uses-permission android:name="xx.xx.xx"/>
    
  2. 第二种方法:可以在服务端的onTransact中进行权限验证,如果验证失败,就直接返回false,这样服务端就不会终止执行AIDL中的方法从而达到保护服务端的效果。可以验证permission,也可以验证Uid和Pid。

    public boolean onTransact(int code,Parcel data,Parcel reply,int flags) throws RemoteException{
        int check = checkCallingOrSelfPermission("xx.xx.xx");
    if(check==PackageManager.PERMISSION_DENIED){
    return false;
    }
    String packageName = null;
    String[] packages = getPackageManager().getPackgesForUid(getCallingUid());
    if(packages!=null&&packages.length>0){
    packageName = packages[0];
    }
    if(!packageName.startWith("xx.xx")){
    return false;
    }
    return super.onTransact(code,data,reply,flags);
    }
  3. 还可以为Service指定android:permission属性等。

2.4.5 使用ContentProvider

2.4.6 使用Socket

2.5 Binder连接池

随着AIDL数量的增加,我们不能无限制地增加Service,Service 是四大组件之一一,本身就是一种系统资源。针对上述问题,我们需要减少Service的数量,将所有的AIDL放在同一个Service中去管理。

每个业务模块创建自己的AIDL接口并实现此接口,这个时候不同业务模块之间是不能有耦合的,所有实现细节我们要单独开来,然后向服务端提供自己的唯一标识和其对应的 Binder 对象;对于服务端来说,只需要一个 Service就可以了,服务端提供一个queryBinder 接口,这个接口能够根据业务模块的特征来返回相应的Binder对象给它们,不同的业务模块拿到所需的Binder对象后就可以进行远程方法调用了。由此可见,Binder连接池的主要作用就是将每个业务模块的Binder请求统一转发到远程Service中去执行,从而避免了重复创建Service的过程。

2.6 选用合适的IPC方式

2019/02/28 posted in  Android开发艺术探索

02 Java内存区域与内存溢出异常

2.1 概述

本章主要介绍Java虚拟机内存的各个区域,及其作用、服务对象以及可能产生的问题。

2.2 运行时数据区域

2.2.1 程序计数器

程序计数器 (Program Counter Register)

  • 线程私有,当前线程所执行的字节码的行号指示器。
  • PC寄存器,保证程序能够连续地执行下去,可以确定下一条指令的地址。

2.2.2 Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)

  • 线程私有。
  • 每个方法在执行的同时都会创建一个栈帧,栈帧存储局部变量表、操作数栈、动态链接、方法出口等信息。调用Java方法时栈帧入栈,方法执行完成栈帧出栈。
    • 栈内存就是虚拟机栈。
    • 局部变量表存放编译期可知的基本数据类型、引用类型和returnAddress类型。
  • 栈容量超出,StackOverflowError;内存不够,OOM。

2.2.3 本地方法栈

本地方法栈(Native Method Stack)

  • Java虚拟机实现可能用到C Stacks支持Native语言。

2.2.4 Java堆

Java堆(Java Heap)

  • 所有线程共享的内存区域。
  • 存放对象实例。
  • Java堆是垃圾收集器管理的主要区域,也被称为GC堆。
  • 按分代收集算法,分为新生代和老年代。
  • 堆中没有内存或无法扩展时,抛出OOM。

2.2.5 方法区

方法区(Method Area)

  • 各个线程共享的内存区域。
  • 存储被Java虚拟机加载的类信息、常量、静态变量、即时编译期编译后的代码等数据。

2.2.6 运行时常量池

运行时常量池(Runtime Constant Pool)

  • 是方法区的一部分。
  • 存放编译期生成的字面量和符号引用。

2.2.7 直接内存

直接内存(Direct Memory)

  • 并不是虚拟机运行时数据区的一部分,会被频繁使用,导致OOM。
  • JDK 1.4引入的NIO,可以使用Native函数库直接分配堆外内存。不受Java堆大小限制,容易被忽略,导致OOM。

2.3 HotSpot虚拟机对象探秘

对象如何创建、如何布局以及如何访问。

2.3.1 对象的创建

  1. 检查类是否被加载、解析、初始化过。
  2. 为新生对象分配内存
    1. Java堆内存绝对规整:指针碰撞。用过的内存在一边,空闲在另一边,中间用指针作为分界点指示器。分配空间就需要把指针移动与对象大小相等的距离。
    2. Java堆内存不规整:虚拟机维护一个表记录内存是否可用。分配空间时从列表中查询、分配,并更新列表。
  3. 处理并发安全问题
    1. 对分配内存的动作进行同步处理,比如在虚拟机采用CAS算法并配上失败重试的方式,保证更新操作的原子性。
    2. 预先分配本地线程分配缓存(Thread Local Allocation Buffer,TLAB),在TLAB上分配内存。当TLAB用完并且被分配到新的TLAB时,才需要同步锁定。
  4. 初始化分配到的内存空间
    1. 除了对象头外都初始化为零。
  5. 设置对象的对象头
    1. 将对象的所属类、HashCode和GC分代年龄等存储在对象头中。
  6. 执行init方法进行初始化
    1. 执行init方法,初始化对象的成员变量、调用类的构造方法,这样一个对象就被创建出来了。

2.3.2 对象的内存布局

  • 对象头(Header)
    • Mark World:存储对象自身的运行时数据,比如HashCode、GC分代年龄、锁状态标志、线程持有的锁等。
    • 元数据指针:用于指向方法区中的目标类的元数据,通过元数据可以确定对象的具体类型。
  • 实例数据(Instance Data)
    • 用于存储对象中的各个类型的字段信息(包括从父类继承来的)
  • 对齐填充(Padding)
    • 不一定存在,起到了占位符的作用。

2.3.3 对象的访问定位

  • 句柄访问
    • Java堆中划出内存作为句柄池,reference中存储对象的句柄地址。句柄中包含对象实例数据与类型数据各自的具体地址信息。
    • 好处:对象被移动时只改变句柄中的实例数据指针,reference不需要修改。
  • 直接指针访问
    • Java堆对象的布局中放置访问类型数据的相关信息,reference中存储对象地址。
    • 好处:速度更快,节省指针定位的实际开销,HotSpot虚拟机中更常使用。

2.4 实战:OutOfMemoryError异常

2.4.1 Java堆溢出

2.4.2 虚拟机栈和本地方法栈溢出

2.4.3 方法区和运行时常量池溢出

2.4.4 本机直接内存溢出

2.5 本章 小结

2019/11/28 posted in  深入理解虚拟机

02 Material Design

2019/03/27 posted in  Android进阶之光

02 | Java内存模型:看Java如何解决可见性和有序性问题

  • 什么是Java内存模型?
  • 使用volatile的困惑
  • Happen-Before规则
    1. 程序的顺序性规则
    2. volatile 变量规则
    3. 传递性
    4. 管程中锁的规则
    5. 线程 start() 规则
    6. 线程 join() 规则
  • 被我们忽视的final
  • 总结

什么是Java内存模型?

导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最合理的方案应该是按需禁用缓存以及编译优化

Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatilesynchronizedfinal 三个关键字,以及六项 Happens-Before 规则。

使用volatile的困惑

volatile 关键字并不是 Java 语言的特产,古老的 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。

Java 内存模型在 1.5 版本对 volatile 语义进行了增强。怎么增强的呢?答案是一项 Happens-Before 规则。

Happen-Before规则

如何理解 Happens-Before 呢?
前面一个操作的结果对后续操作是可见的。Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。

1. 程序的顺序性规则

在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。

2. volatile 变量规则

对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

3. 传递性

如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

4. 管程中锁的规则

对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

“管程指的是什么”。管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。
管程中的锁在 Java 里是隐式实现的,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

5. 线程 start() 规则

这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

6. 线程 join() 规则

这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

被我们忽视的final

有没有办法告诉编译器优化得更好一点呢?这个可以有,就是final关键字。final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。

总结

在 Java 语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。例如 A 事件发生在线程 1 上,B 事件发生在线程 2 上,Happens-Before 规则保证线程 2 上也能看到 A 事件的发生。

课后思考

有一个共享变量 abc,在一个线程里设置了 abc 的值 abc=3,你思考一下,有哪些办法可以让其他线程能够看到abc==3?

答:

  1. 声明共享变量abc,并使用volatile关键字修饰abc
  2. 声明共享变量abc,在synchronized关键字对abc的赋值代码块加锁,由于Happen-before管程锁的规则,可以使得后续的线程可以看到abc的值。
  3. A线程启动后,使用A.JOIN()方法来完成运行,后续线程再启动,则一定可以看到abc==3
2019/12/05 posted in  极客-Java并发实战

02 | Java的基本类型

  • Java 虚拟机的 boolean 类型
  • Java 的基本类型
  • Java 基本类型的大小

Java 虚拟机的 boolean 类型

在 Java 虚拟机规范中,boolean 类型则被映射成 int 类型。具体来说,“true”被映射为整数 1,而“false”被映射为整数 0。

Java 的基本类型

除了上面提到的 boolean 类型外,Java 的基本类型还包括整数类型 byte、short、char、int 和 long,以及浮点类型 float 和 double。

Java 的基本类型都有对应的值域和默认值。可以看到,byte、short、int、long、float 以及 double 的值域依次扩大,而且前面的值域被后面的值域所包含。因此,从前面的基本类型转换至后面的基本类型,无需强制转换。另外一点值得注意的是,尽管他们的默认值看起来不一样,但在内存中都是 0。

Java 基本类型的大小

在第一篇中我曾经提到,Java 虚拟机每调用一个 Java 方法,便会创建一个栈帧。为了方便理解,这里我只讨论供解释器使用的解释栈帧(interpreted frame)。

这种栈帧有两个主要的组成部分,分别是局部变量区,以及字节码的操作数栈。这里的局部变量是广义的,除了普遍意义下的局部变量之外,它还包含实例方法的“this 指针”以及方法所接收的参数。

1.存储

在 Java 虚拟机规范中,局部变量区等价于一个数组,并且可以用正整数来索引。除了 long、double 值需要用两个数组单元来存储之外,其他基本类型以及引用类型的值均占用一个数组单元。

也就是说,boolean、byte、char、short 这四种类型,在上占用的空间和 int 是一样的,和引用类型也是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。

当然,这种情况仅存在于局部变量,而并不会出现在存储于中的字段或者数组元素上。对于 byte、char 以及 short 这三种类型的字段或者数组单元,它们在堆上占用的空间分别为一字节、两字节,以及两字节,也就是说,跟这些类型的值域相吻合。

1.加载
讲完了存储,现在我来讲讲加载。Java 虚拟机的算数运算几乎全部依赖于操作数栈。也就是说,我们需要将堆中的 boolean、byte、char 以及 short 加载到操作数栈上,而后将栈上的值当成 int 类型来运算。

对于 boolean、char 这两个无符号类型来说,加载伴随着零扩展。举个例子,char 的大小为两个字节。在加载时 char 的值会被复制到 int 类型的低二字节,而高二字节则会用 0 来填充。

对于 byte、short 这两个类型来说,加载伴随着符号扩展。举个例子,short 的大小为两个字节。在加载时 short 的值同样会被复制到 int 类型的低二字节。如果该 short 值为非负数,即最高位为 0,那么该 int 类型的值的高二字节会用 0 来填充,否则用 1 来填充。

总结与实践

今天我介绍了 Java 里的基本类型。

其中,boolean 类型在 Java 虚拟机中被映射为整数类型:“true”被映射为 1,而“false”被映射为 0。Java 代码中的逻辑运算以及条件跳转,都是用整数相关的字节码来实现的。

除 boolean 类型之外,Java 还有另外 7 个基本类型。它们拥有不同的值域,但默认值在内存中均为 0。这些基本类型之中,浮点类型比较特殊。基于它的运算或比较,需要考虑 +0.0F、-0.0F 以及 NaN 的情况。

除 long 和 double 外,其他基本类型与引用类型在解释执行的方法栈帧中占用的大小是一致的,但它们在堆中占用的大小确不同。在将 boolean、byte、char 以及 short 的值存入字段或者数组单元时,Java 虚拟机会进行掩码操作。在读取时,Java 虚拟机则会将其扩展为 int 类型。

2019/12/14 posted in  极客-深入拆解Java虚拟机

02 单例模式

确保对象的唯一性——单例模式

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。
注意:
1、单例类只能有一个实例。
2、单例类必须自己创建自己的唯一实例。
3、单例类必须给所有其他对象提供这一实例。

介绍

意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
主要解决:一个全局使用的类频繁地创建与销毁。
何时使用:当您想控制实例数目,节省系统资源的时候。
如何解决:判断系统是否已经有这个单例,如果有则返回,如果没有则创建。
关键代码:构造函数是私有的。

懒汉式,线程安全

优点:第一次调用才初始化,避免内存浪费。
缺点:必须加锁 synchronized 才能保证单例,但加锁会影响效率。

public class Singleton {  
    private static Singleton instance;  
    private Singleton (){}  
    public static synchronized Singleton getInstance() {  
    if (instance == null) {  
        instance = new Singleton();  
    }  
    return instance;  
    }  
}

饿汉式

优点:没有加锁,执行效率会提高。
缺点:类加载时就初始化,浪费内存。

public class Singleton {  
    private static Singleton instance = new Singleton();  
    private Singleton (){}  
    public static Singleton getInstance() {  
    return instance;  
    }  
}

双检锁/双重校验锁(DCL,即 double-checked locking)

描述:这种方式采用双锁机制,安全且在多线程情况下能保持高性能。
缺点:synchronized性能效率问题

public class Singleton {  
    private volatile static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {  
    if (singleton == null) {  
        synchronized (Singleton.class) {  
        if (singleton == null) {  
            singleton = new Singleton();  
        }  
        }  
    }  
    return singleton;  
    }  
}

静态内部类

public class Singleton {  
    private static class SingletonHolder {  
    private static final Singleton INSTANCE = new Singleton();  
    }  
    private Singleton (){}  
    public static final Singleton getInstance() {  
    return SingletonHolder.INSTANCE;  
    }  
}

枚举

public enum Singleton {  
    INSTANCE;  
    public void whateverMethod() {  
    }  
}
2019/11/01 posted in  菜鸟-设计模式

02 如何抓住重点,系统高效地学习数据结构和算法?

什么是数据结构?什么是算法?

从广义上讲,数据结构就是指一组数据的存储结构。算法就是操作数据的一组方法。

从狭义上讲,是指某些著名的数据结构和算法,比如队列、栈、堆、二分查找、动态规划等。

我们要讲的这些经典数据结构和算法,都是前人从很多实际操作场景中抽象出来的,可以高效地帮助我们解决很多实际的开发问题。

那数据结构和算法有什么关系呢?

数据结构和算法是相辅相成的。数据结构是为算法服务的,算法要作用在特定的数据结构之上。
比如,因为数组具有随机访问的特点,常用的二分查找算法需要用数组来存储数据。但如果我们选择链表这种数据结构,二分查找算法就无法工作了,因为链表并不支持随机访问。

学习的重点在什么地方?

想要学习数据结构与算法,首先要掌握一个数据结构与算法中最重要的概念——复杂度分析。

数据结构和算法解决的是如何更省、更快地存储和处理数据的问题,因此,我们就需要一个考量效率和资源消耗的方法,这就是复杂度分析方法。

搞定复杂度分析,下面就要进入数据结构与算法的正文内容了。

其中包括20个最常用的、最基础数据结构与算法:

  • 数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie 树;
  • 算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法。

在学习数据结构和算法的过程中,要注意学习它的“来历”“自身的特点”“适合解决的问题”以及“实际的应用场景”

多辩证地思考,多问为什么。

学习技巧

  1. 边学边练,适度刷题
  2. 多问、多思考、多互动
  3. 给自己设立一个切实可行的目标
  4. 知识需要沉淀,不要想试图一下子掌握所有
2019/04/01 posted in  极客-数据结构与算法之美

02Java程序设计环境

2019/03/28 posted in  Java核心技术

03 Builder模式

复杂对象的组装与创建——建造者模式

建造者模式(Builder Pattern)使用多个简单的对象一步一步构建成一个复杂的对象。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
一个 Builder 类会一步一步构造最终的对象。该 Builder 类是独立于其他对象的。

介绍

意图:将一个复杂的构建与其表示相分离,使得同样的构建过程可以创建不同的表示。
主要解决:主要解决在软件系统中,有时候面临着"一个复杂对象"的创建工作,其通常由各个部分的子对象用一定的算法构成;由于需求的变化,这个复杂对象的各个部分经常面临着剧烈的变化,但是将它们组合在一起的算法却相对稳定。
何时使用:一些基本部件不会变,而其组合经常变化的时候。
如何解决:将变与不变分离开。
关键代码:建造者:创建和提供实例,导演:管理建造出来的实例的依赖关系。
应用实例: 1、去肯德基,汉堡、可乐、薯条、炸鸡翅等是不变的,而其组合是经常变化的,生成出所谓的"套餐"。 2、JAVA 中的 StringBuilder。
优点: 1、建造者独立,易扩展。 2、便于控制细节风险。
缺点: 1、产品必须有共同点,范围有限制。 2、如内部变化复杂,会有很多的建造类。
使用场景: 1、需要生成的对象具有复杂的内部结构。 2、需要生成的对象内部属性本身相互依赖。
注意事项:与工厂模式的区别是:建造者模式更加关注与零件装配的顺序。

2019/11/01 posted in  菜鸟-设计模式

03 View体系与自定义View

3.1 View与ViewGroup

3.2 坐标系

3.2.1 Android坐标系

3.2.2 View坐标系

3.3 View的滑动

3.3.1 layout()方法

3.3.2 offsetLeftAndRight()与offsetTopAndBottom()

3.3.3 LayoutParams(改变布局参数)

3.3.4 动画

3.3.5 scrollTo与scollBy

3.3.6 Scroller

3.4 属性动画

3.5 解析Scroller

3.6 View的事件分发机制

3.6.1 源码解析Activity的构成

3.6.2 源码解析View的事件分发机制

3.7 View的工作流程

3.7.1 View的工作流程入口

3.7.2 理解MeasureSpec

3.7.3 View的measure流程

3.7.4 View的layout流程

3.7.5 View的draw流程

3.8 自定义View

3.8.1 继承系统控件的自定义View

3.8.2 继承View的自定义View

3.8.3 自定义组合控件

3.8.4 自定义ViewGroup

3.9 本章小结

2019/12/11 posted in  Android进阶之光

03 View的事件体系

3.1 View基础知识

3.1.1 什么是View

  • View是Android中所有控件的基类,是界面层的控件的一种抽象
  • ViewGroup,继承View,内部包含了许多控件
  • 通过View和ViewGroup构成View树的结构,类似于Web中的Dom树

3.1.2 View的位置参数

  • 坐标:X轴和Y轴,正方向为右和下
  • View的属性:
    • x/y:View左上角的位置
    • top/left/right/bottom:上下左右
    • translationX/translationY:平移量

3.1.3 MotionEvent和TouchSlop

1.MotionEvent
手指接触屏幕后产生的事件。

属性 说明
ACTION_DOWN 按下
ACTION_MOVE 滑动
ACTION_UP 离开
ACTION_CANCEL 取消
ACTION_OUTSIDE 超出边界
ACTION_POINTER_DOWN 多点按下
ACTION_POINTER_UP 多点离开

ACTION_CANCEL:从当前控件转移到外层控件时会触发

获得点击事件发生时的X Y坐标:

  • getX()/getY():相对控件的位置
  • getRawX()/getRawY():相对整个屏幕的位置

2.TouchSlop

系统能识别的滑动最小距离,和设备相关。
获取方法:

ViewConfiguration.get(getContext()).getScaledTouchSlop()

3.1.4 VelocityTracker、GestureDetector和Scroller

1.VelocityTracker

速度追踪

使用:
首先在view的onTouchEvent方法中追踪当前单击事件的速度:

VelocityTracker velocityTracker = VelocityTracker.obtain();//实例化一个VelocityTracker 对象
velocityTracker.addMovement(event);//添加追踪事件

接着在ACTION_UP事件中获取当前的速度:

velocityTracker .computeCurrentVelocity(1000);//指定时间
float xVelocity = velocityTracker .getXVelocity();//水平方向
float yVelocity = velocityTracker .getYVelocity();//垂直方向

最后,当不需要使用它的时候,释放、回收:

/*清除释放*/
velocityTracker.clear();
velocityTracker.recycle()

速度 = (终点位置-起点位置)/时间

2.GestureDetector

手势检测,辅助检测用户单击、滑动、长按、双击等行为

使用:

创建一个GestureDetecor对象并实现OnGestureListener接口:

GestureDetector mGestureDetector = new GestureDetector(this);//实例化一个GestureDetector对象
mGestureDetector.setIsLongpressEnabled(false);// 解决长按屏幕后无法拖动的现象

然后,在待监听view的onTouchEvent方法中添加如下实现:

//目标view的onTouchEvent方法中修改返回值
boolean consume = mGestureDetector.onTouchEvent(event);
return consume;

建议:

监听滑动,使用onTouchEvent
监听双击,使用GestureDetector

3.Scroller

使用Scroller的实现滑动的步骤:

  1. 初始化一个Scroller对象
  2. 重写View.computeScroll()方法
  3. 调用startScroll()开始滑动,invalidate()重绘
Scroller scroller = new Scroller(mContext); //实例化一个Scroller对象

@Override
public void computeScroll() {
    super.computeScroll();
    if(mScroller.computeScrollOffset()){
        ((View) getParent()).scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
         //通过不断的重绘不断的调用computeScroll方法
         invalidate();
    }  
}

public void smoothScrollTo(int destX,int destY){
      int scrollX=getScrollX();
      int delta=destX-scrollX;
      //1000秒内滑向destX
      mScroller.startScroll(scrollX,0,delta,0,2000);
      invalidate();
  }

3.2 View的滑动

3.2.1 使用scrollTo/scrollBy

  • scrollTo:基于所传参数的绝对滑动
  • scrollBy:基于当前位置的相对滑动,调用的也是scrollTo方法。

3.2.2 使用动画

1.视图动画:

//translate.xml
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate android:fromXDelta="0" android:toXDelta="300" android:duration="1000"/>
</set>

//Java代码
mCustomView.setAnimation(AnimationUtils.loadAnimation(this, R.anim.translate));

2.属性动画:

ObjectAnimator.ofFloat(mCustomView,"translationX",0,300).setDuration(1000).start();

3.2.3 改变布局参数

通过改变View的布局参数,实现View的滑动。

LinearLayout.LayoutParams layoutParams= (LinearLayout.LayoutParams) getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);

3.2.4 各种滑动方式的对比

针对上面的分析做一下总结,如下所示:

  • scrollTo/scrollBy:操作简单,适合对 View 内容的滑动;
  • 动画:操作简单,主要适用于没有交互的View 和实现复杂的动画效果;
  • 改变布局参数:操作稍微复杂,适用于有交互的View。

3.3 弹性滑动

弹性滑动思想:将一次大的滑动分成若干次小的滑动
并在一个时间段內完成,弹性滑动的具体实现方式有很多,比如通过 Scroller、Handler#postDelayed以及Thread#sleep等。

3.3.1 使用Scroller

Scroller scroller = new Scroller(mContext); //实例化一个Scroller对象

@Override
public void computeScroll() {
    super.computeScroll();
    if(mScroller.computeScrollOffset()){
        ((View) getParent()).scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
         //通过不断的重绘不断的调用computeScroll方法
         invalidate();
    }  
}

public void smoothScrollTo(int destX,int destY){
      int scrollX=getScrollX();
      int delta=destX-scrollX;
      //1000秒内滑向destX
      mScroller.startScroll(scrollX,0,delta,0,2000);
      invalidate();
  }

分析:
Scroller.invalidate方法导致View重绘,View.draw方法被执行,然后在重写的View.computeScroll方法中,会判断computeScrollOffset,true则调用scrollTo/scrollBy滚动、invalidate/postInvalidate再次重绘,如此反复,直到computeScrollOffset为false。

3.3.2 通过动画

动画本身就是一种渐近的过程,因此通过它来实现的滑动天然就具有弹性效果,比如以下代码可以让一个 View 的内容在 100ms 内向左移动 100 像素。

ObjectAnimator.ofFloat (targetView, “translationX”, 0, 100).setDuration
(100).start () ;

我们还可以利用动画的特性来实现一些动画不能实现的效果,例如:

final int startX = 0;
final int deltaX = 100;

ValueAnimator animator = ValueAnimator.ofInt (0, 1).setDuration (1000);
animator.addUpdateListener (new AnimatorUpdateListener () {
    @Override
    public void onAnimationUpdate (ValueAnimator animator) {
        float fraction =  animator.getAnimatedFraction ();
        mButton1.scrollTo (startX + (int) (deltaX * fraction), 0);
    }
});
animator.start ();

利用onAnimationUpdate,我们就可以在动画的每一帧到来时获取动画完成的比例,然后再根据这个比例计算出当前View 所要滑动的距离。

3.3.3 使用延时策略

3.4 View的事件分发机制

3.4.1 点击事件的传递规则

所谓点击事件的事件分发,其实就是对 MotionEvent事件的分发过程,即当一个MotionEvent 产生了以后,系统需要把这个事件传递给一个具体的View,而这个传递的过程就是分发过程。

事件分发的三个方法:

  • dispatchTouchEvent
    • 用来进行事件的分发。如果事件能够传递给当前 View,那么此方法一定会被调用,返回结果受当前View 的 onTouchEvent 和下级 View的dispatchTouchEvent 方法的影响,表示是否消耗当前事件
  • onInterceptTouchEvent
    • 在 dispatchTouchEvent 方法中调用,用来判断是否拦截某个事件,如果当前 View 拦截了某个事件,那么在同一个事件序列当中,此方法不会被再次调用,返回结果表示是否拦截当前事件。
  • onTouchEvent
    • 在 dispatchTouchEvent 方法中调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前 View 无法再次接收到事件。
//伪代码
public boolean dispatchTouchEvent(MotionEvent ev) {
    boolean result = false;
    if(onInterceptTouchEvent(ev)) { //是否拦截
        result = onTouchEvent(ev);  //拦截,处理事件
    } else {    //不拦截,分发事件
        result = child.dispatchTouchEvent(ev);
    }
    return result;
}

点击事件和监听优先级
onTouchListener > onTouchEvent > onClickListener

当一个点击事件产生后,它的传递过程遵循如下顺序: Activity-> Window-> View,即事件总是先传递给 Activity, Activity 再传递给 Window,最后后Window 再传递给顶级 View。顶级 View 接收到事件后,就会按照事件分发机制去分发事件。
考虑一种情况,如果一个View 的 onTouchEvent 返回false,那么它的父容器的 onTouchEvent 将会被调用,依此类推。如果所有的元素都不处理这个事件,那么这个事件将会最终传递给 Activity 处理,即Activity的onTouchEvent 方法会被调用。

3.4.2 事件分发的源码解析

1.Activity对点击事件的分发过程

  1. Activity#dispatchTouchEvent

    public boolean dispatchTouchEvent (MotionEvent ev) {
        if (ev.getAction () == MotionEvent.ACTION DOWN) {
    onUserInteraction ();
    }
    if (getWindow ().superDispatchTouchEvent (ev)) {
    return true;
    }
    return onTouchEvent (ev) ;
    }
  2. Window#superDispatchTouchEvent

    Window 类可以控制顶级 View 的外观和行为策略,它的唯一实现位于 android.policy.PhoneWindow 。

    public abstract boolean superDispatchTouchEvent(MotionEvent event);
    
  3. PhoneWindow#superDispatchTouchEvent

    @Override
    public boolean superDispatchTouchEvent(MotionEvent event) {
    return mDecor.superDispatchTouchEvent(event);
    }
  4. DecorView#getDecorView

    顶层View,是一个FrameLayout。

2.顶级View对点击事件的分发过程

  • 如果顶级 ViewGroup 拦截事件即onInterceptTouchEvent 返回 true,则事件由 ViewGroup 处理,这时如果 ViewGroup 的mOnTouchListener 被设置,则 onTouch 会被调用,否则 onTouchEvent会被调用。也就是说,如果都提供的话,onTouch 会屏蔽掉 onTouchEvent。在onTouchEvent中,如果设置了mOnClickListener,则 onClick 会被调用。
  • 如果顶级ViewGroup 不拦截事件,则事件会传递给它所在的点击事件链上的子 View,这时子 View 的 dispatchTouchEvent 会被调用。到此为止,事件已经从顶级 View 传递给了下一层 View,接下来的传递过程和顶级 View 是一致的,如此循环,完成整个事件的分发。

ViewGroup#dispatchTouchEvent

  1. 判断当前View是否拦截点击事件

    // Check for interception.
    final boolean intercepted;
    if (actionMasked == MotionEvent.ACTION_DOWN
    || mFirstTouchTarget != null) {
    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
    if (!disallowIntercept) {
    intercepted = onInterceptTouchEvent(ev);
    ev.setAction(action); // restore action in case it was changed
    } else {
    intercepted = false;
    }
    } else {
    // There are no touch targets and this action is not an initial down
    // so this view group continues to intercept touches.
    intercepted = true;
    }
    • 判断拦截:
      1. MotionEvent.ACTION_DOWN
      2. mFirstTouchTarget != null
      3. 判断mGroupFlags,可通过子View的requestDisallowInterceptTouchEvent()设置
  2. ViewGroup遍历子所有View

    1. 判断子元素是否能接受这个点击事件。主要是通过两点:
      1. 子元素是否在播放动画
      2. 点击事件的着落点是否在子元素的区域内。
    2. dispatchTransformedTouchEvent()
      1. 实际就是调用子元素的dispatchTouchEvent()方法。
      2. 返回值为true,那么mFirstTouchTarget将会赋值并且跳出循环
      3. 返回值为false,将事件分发给下一个子View。
    final View[] children = mChildren;
    for (int i = childrenCount - 1; i >= 0; i--){
    ...
    //2. 通过dispatchTransformedTouchEvent()将事件传递给子View
    if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
    // Child wants to receive touch within its bounds.
    mLastTouchDownTime = ev.getDownTime();
    if (preorderedList != null) {
    // childIndex points into presorted list, find original index
    for (int j = 0; j < childrenCount; j++) {
    if (children[childIndex] == mChildren[j]) {
    mLastTouchDownIndex = j;
    break;
    }
    }
    } else {
    mLastTouchDownIndex = childIndex;
    }
    mLastTouchDownX = ev.getX();
    mLastTouchDownY = ev.getY();
    //3.对mFirstTouchTarget赋值
    newTouchTarget = addTouchTarget(child, idBitsToAssign);
    alreadyDispatchedToNewTouchTarget = true;
    break;
    }
    ev.setTargetAccessibilityFocus(false);
    }
    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
            View child, int desiredPointerIdBits) {
    final boolean handled;
    if (child == null) {
    handled = super.dispatchTouchEvent(transformedEvent);
    } else {
    handled = child.dispatchTouchEvent(transformedEvent);
    }
    return handled;
    }
    private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
        final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
    target.next = mFirstTouchTarget;
    //mFirstTouchTarget为链表结构
    mFirstTouchTarget = target;
    return target;
    }

    mFirstTouchTarget是否赋值,将会影响ViewGroup的拦截策略。如果mFirstTouchTarget为null,那么ViewGroup将会拦截下来同一序列的所有事件,自己处理并不再向子元素传递。那mFirstTouchTarget在什么情况下才为null呢?一般在两种情况下,

    • 要么是ViewGroup遍历了所有的子元素事件没有被处理;
    • 要么是子元素处理了ACTION_DOWN但是dispatchTouchEvent返回为false。
    if (mFirstTouchTarget == null) {
        // 此处的第三个参数为null,代表事件交给ViewGroup自己处理
    handled = dispatchTransformedTouchEvent(ev, canceled, null,
    TouchTarget.ALL_POINTER_IDS);
    }

3.View对点击事件的处理过程

  1. 是否设置了onTouchListener()
  2. 是:调用onTouch()
  3. 否:调用onTouchEvent()

onTouch()的优先级要高于onTouchEvent()。

public boolean dispatchTouchEvent(MotionEvent event) {
    //...
    //首先判断是否设置了onTouchListener()
    ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnTouchListener != null
            && (mViewFlags & ENABLED_MASK) == ENABLED
            && li.mOnTouchListener.onTouch(this, event)) {
        result = true;
    }
    //调用onTouchEvent(event)
    if (!result && onTouchEvent(event)) {
        result = true;
    }
    //...
    return result;
}

View的点击事件是在ACTION_UP事件中调用了performClick()方法处理,长按事件是在ACTION_DOWN事件中调用了checkForLongClick()方法处理。

public boolean onTouchEvent(MotionEvent event) {
    ...
    //如果View设有代理,将会执行TouchDelegate.onTouchEvent(event)
    if (mTouchDelegate != null) {
        if (mTouchDelegate.onTouchEvent(event)) {
            return true;
        }
    }
    //只要View的CLICKABLE和LONG_CLICKABLE有一个返回true,他就会被消耗这个事件。
    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
        switch (action) {
            case MotionEvent.ACTION_UP:
                  ...
                        if (!focusTaken) {
                            if (mPerformClick == null) {
                                mPerformClick = new PerformClick();
                            }
                            //点击事件
                            if (!post(mPerformClick)) {
                                performClick();
                            }
                        }
                    ...
                mIgnoreNextUpEvent = false;
                break;

            case MotionEvent.ACTION_DOWN:
                ...
                //长按事件
                if (!clickable) {
                    checkForLongClick(0, x, y);
                    break;
                }
                ...
                break;
                ...
        }
        return true;
    }
    return false;
}

3.5 View的滑动冲突

3.5.1 常见的滑动冲突场景

  1. 外部滑动方向和内部滑动方向不一致;
  2. 外部滑动方向和内部滑动方向一致;
  3. 上面两种情况的嵌套。

3.5.2 滑动冲突的处理规则

  1. 场景1,当用户左右滑动时,让外部的View拦截点击事件;当用户上下滑动时,让内部View拦截点击事件。
  2. 场景2,根据业务不同进行判断,让外部或内部View拦截处理。
  3. 场景3,相对复杂,根据业务进行处理。

如何判断是左右滑动还是上下滑动?
根据水平或垂直方向的距离差夹角速度差进行判断。

3.5.3 滑动冲突的解决方式

1)外部拦截法

父容器如果需要此事件就拦截,重写父容器的onInterceptTouchEvent方法。

public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted = false;
    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN: {
        intercepted = false;    //!!!
        break;
    }
    case MotionEvent.ACTION_MOVE: {
        if (满足父容器的拦截要求) {
            intercepted = true;
        } else {
            intercepted = false;
        }
        break;
    }
    case MotionEvent.ACTION_UP: {
        intercepted = false;
        break;
    }
    default:
        break;
    }
    mLastXIntercept = x;
    mLastYIntercept = y;
    return intercepted;
}

2)内部拦截法

父容器不拦截任何事件,所有事件传递给子元素,如果子元素需要就直接消耗,否则就交给父容器处理。父容器需要的话,调用parent.requestDisallowInterceptTouchEvent方法。

public boolean dispatchTouchEvent(MotionEvent event) {
    int x = (int) event.getX();
    int y = (int) event.getY();

    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN: {
        parent.requestDisallowInterceptTouchEvent(true);
        break;
    }
    case MotionEvent.ACTION_MOVE: {
        int deltaX = x - mLastX;
        int deltaY = y - mLastY;
        if (父容器需要此类点击事件) {
            parent.requestDisallowInterceptTouchEvent(false);
        }
        break;
    }
    case MotionEvent.ACTION_UP: {
        break;
    }
    default:
        break;
    }

    mLastX = x;
    mLastY = y;
    return super.dispatchTouchEvent(event);
}

重写父容器的onInterceptTouchEvent方法。

public boolean onInterceptTouchEvent(MotionEvent event) {
    int action = event.getAction();
    if (action == MotionEvent.ACTION_DOWN) {
        return false;
    } else {
        return true;
    }
}
2019/12/11 posted in  Android开发艺术探索

03 | Java虚拟机是如何加载Java类的?

Java 语言的类型可以分为两大类:基本类型(primitive types)和引用类型(reference types)。

  • Java 的基本类型,它们是由 Java 虚拟机预先定义好的。
  • 引用类型,Java 将其细分为四种:类、接口、数组类和泛型参数。由于泛型参数会在编译过程中被擦除(我会在专栏的第二部分详细介绍),因此 Java 虚拟机实际上只有前三种。在类、接口和数组类中,数组类是由 Java 虚拟机直接生成的,其他两种则有对应的字节流。

说到字节流,最常见的形式要属由 Java 编译器生成的 class 文件。除此之外,我们也可以在程序内部直接生成,或者从网络中获取(例如网页中内嵌的小程序 Java applet)字节流。这些不同形式的字节流,都会被加载到 Java 虚拟机中,成为类或接口。

无论是直接生成的数组类,还是加载的类,Java 虚拟机都需要对其进行链接和初始化。

加载

加载,是指查找字节流,并且据此创建类的过程。对于数组类来说,它并没有对应的字节流,而是由 Java 虚拟机直接生成的。对于其他的类来说,Java 虚拟机则需要借助类加载器来完成查找字节流的过程。

启动类加载器是由 C++ 实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 来指代。除了启动类加载器之外,其他的类加载器都是 java.lang.ClassLoader 的子类,因此有对应的 Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 Java 虚拟机中,方能执行类加载。

双亲委派模型:每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。

在 Java 9 之前,启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。除了启动类加载器之外,另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器(application class loader),均由 Java 核心类库提供。

Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。

在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。

链接

链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。它可分为验证、准备以及解析三个阶段。

验证阶段的目的,在于确保被加载类能够满足 Java 虚拟机的约束条件。

准备阶段的目的,则是为被加载类的静态字段分配内存。除了分配内存外,部分 Java 虚拟机还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。

在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。
解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)

Java 虚拟机规范并没有要求在链接过程中完成解析。它仅规定了:如果某些字节码使用了符号引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。

初始化

在 Java 代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。

如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >。

类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。

那么,类的初始化何时会被触发呢?JVM 规范枚举了下述多种触发情况:

  1. 当虚拟机启动时,初始化用户指定的主类;
  2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
  5. 子类的初始化会触发父类的初始化;
  6. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  7. 使用反射 API 对某个类进行反射调用时,初始化这个类;
  8. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

总结与实践

今天我介绍了 Java 虚拟机将字节流转化为 Java 类的过程。这个过程可分为加载、链接以及初始化三大步骤。

加载是指查找字节流,并且据此创建类的过程。加载需要借助类加载器,在 Java 虚拟机中,类加载器使用了双亲委派模型,即接收到加载请求时,会先将请求转发给父类加载器。

链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。链接还分验证、准备和解析三个阶段。其中,解析阶段为非必须的。

初始化,则是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。类的初始化仅会被执行一次,这个特性被用来实现单例的延迟初始化。

2019/12/14 posted in  极客-深入拆解Java虚拟机

03 | 互斥锁(上):解决原子性问题

  • 简易锁模型
  • 改进后的锁模型
  • Java 语言提供的锁技术:synchronized
  • 用 synchronized 解决 count+=1 问题
  • 锁和受保护资源的关系

那原子性问题到底该如何解决呢?

“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。

简易锁模型

我们把一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁 lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;否则呢就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁 unlock()。

改进后的锁模型

锁和锁要保护的资源是有对应关系的,在并发编程世界里,锁和资源也应该有这个关系,但这个关系在我们上面的模型中是没有体现的,所以我们需要完善一下我们的模型。

  1. 首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源 R;
  2. 其次,我们要保护资源 R 就得为它创建一把锁 LR;
  3. 最后,针对这把锁 LR,我们还需在进出临界区时添上加锁操作和解锁操作。
  4. 另外,在锁 LR 和受保护资源之间,我特地用一条线做了关联,这个关联关系非常重要。很多并发 Bug 的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情。

Java 语言提供的锁技术:synchronized

锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本上都是下面这个样子:

class X {
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }
  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }
  // 修饰代码块
  Object obj = new Object();
  void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}  
  • Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock()。
  • 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;当修饰非静态方法的时候,锁定的是当前实例对象 this。
class X {
  // 修饰静态方法
  synchronized(X.class) static void bar() {
    // 临界区
  }
}
class X {
  // 修饰非静态方法
  synchronized(this) void foo() {
    // 临界区
  }
}

用 synchronized 解决 count+=1 问题

锁和受保护资源的关系

受保护资源和锁之间的关联关系是 N:1 的关系。

class SafeCalc {
  static long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}

如果你仔细观察,就会发现改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class。我们可以用下面这幅图来形象描述这个关系。由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。

总结

互斥锁,在并发领域的知名度极高,只要有了并发问题,大家首先容易想到的就是加锁,因为大家都知道,加锁能够保证执行临界区代码的互斥性。临界区的代码是操作受保护资源的路径,不是随便一把锁都能有效。所以必须深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。

课后思考

下面的代码用 synchronized 修饰代码块来尝试解决并发问题,你觉得这个使用方式正确吗?有哪些问题呢?能解决可见性和原子性问题吗?

class SafeCalc {
  long value = 0L;
  long get() {
    synchronized (new Object()) {
      return value;
    }
  }
  void addOne() {
    synchronized (new Object()) {
      value += 1;
    }
  }
}

答:加锁本质就是在锁对象的对象头中写入当前线程id,但是new object每次在内存中都是新对象,所以加锁无效。
老师:一个合理的受保护资源与锁之间的关联关系应该是 N:1。只有共享一把锁才能起到互斥的作用。

2019/12/05 posted in  极客-Java并发实战

03 垃圾收集器与内存分配策略

3.1 概述

GC需要完成的3件事情:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

3.2 对象已死吗

垃圾收集器在堆进行回收前,要确定对象是否仍被使用。

3.2.1 引用计数算法

3.2.2 可达性分析算法

3.2.3 再谈引用

3.2.4 生存还是死亡

3.2.5 回收方法区

3.3 垃圾收集算法

3.3.1 标记—清除算法

3.3.2 复制算法

3.3.3 标记—整理算法

3.3.4 分代收集算法

3.4 HotSpot的算法实现

3.4.1 枚举根节点

3.4.2 安全点

3.4.3 安全区域

3.5 垃圾收集器

3.5.1 Serial收集器

3.5.2 ParNew收集器

3.5.3 ParallelScavenge收集器

3.5.4 SerialOld收集器

3.5.5 ParallelOld收集器

3.5.6 CMS收集器

3.5.7 G1收集器

3.5.8 理解GC日志

3.5.9 垃圾收集器参数总结

3.6 内存分配与回收策略

3.6.1 对象优先在Eden分配

3.6.2 大对象直接进入老年代

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

3.6.4 动态对象年龄判定

3.6.5 空间分配担保

3.7 本章 小结

2019/11/28 posted in  深入理解虚拟机

03 复杂度分析上:如何分析、统计算法的还行效率和资源消耗?

为什么需要复杂度分析?

事后统计法的局限性:

  1. 测试结果非常依赖测试环境
  2. 测试结果受数据规模的影响很大

我们需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法。这就是我们今天要讲的时间、空间复杂度分析方法。

大 O 复杂度表示法

所有代码的执行时间 T(n) 与每行代码的执行次数 n 成正比。

大 O 时间复杂度表示法,表示代码执行时间随数据规模增长的变化趋势,也叫作渐进时间复杂度,简称时间复杂度。

时间复杂度分析

  1. 只关注循环执行次数最多的一段代码
  2. 加法法则:总复杂度等于量级最大的那段代码的复杂度
  3. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

几种常见时间复杂度实例分析

粗略地分为两类,多项式量级和非多项式量级。其中,非多项式量级只有两个:O(2n) 和 O(n!)。

下面是几种常见的多项式时间复杂度:

  1. O(1)
    1. 一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)。
  2. O(logn)、O(nlogn)
    1. 在采用大 O 标记复杂度的时候,可以忽略系数,即 O(Cf(n)) = O(f(n))。
  3. O(m+n)、O(m*n)
    1. 代码的复杂度由两个数据的规模来决定。

空间复杂度分析

空间复杂度全称就是渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系。

我们常见的空间复杂度就是 O(1)、O(n)、O(n2 ),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。而且,空间复杂度分析比时间复杂度分析要简单很多。所以,对于空间复杂度,掌握刚我说的这些内容已经足够了。

2019/04/01 posted in  极客-数据结构与算法之美

03 应用程序进程启动过程

3.1 应用程序进程简介

要想启动一个应用程序,首先要保证这个应用程序所需要的应用程序进程已经启动。AMS在启动应用程序时会检查这个应用程序需要的应用程序进程是否存在,不存在就会请求Zygote进程启动需要的应用程序进程。在2.2节中,我们知道在Zygote的Java框架层中会创建一个Server端的Socket,这个Socket用来等待AMS请求Zygote来创建新的应用程序进程。Zygote进程通过fock自身创建应用程序进程,这样应用程序进程就会获得Zygote进程在启动时创建的虚拟机实例。当然,在应用程序进程创建过程中除了获取虚拟机实例外,还创建了Binder 线程池和消息循环,这样运行在应用进程中的应用程序就可以方便地使用Binder进行进程间通信以及处理消息了。

3.2 应用程序进程启动过程介绍

3.2.1 AMS发送启动应用程序进程请求

3.2.2 Zygote接收请求并创建应用程序进程

3.3 Binder线程池启动过程

3.4 消息循环创建过程

3.5 本章小结

2019/03/27 posted in  Android进阶解密

03Java的基本程序设计结构

2019/03/28 posted in  Java核心技术

04 View的工作原理

4.1 初识ViewRoot和DecorView

  1. ActivityThread#handleResumeActivity
    1. 当 Activity 对象被创建完毕后,会将 DecorView 通过 WindowManager 添加到 Window 中。
  2. WindowManagerGlobal#addView
    1. 创建 ViewRootImpl 对象,并将 ViewRootImpl 对象和 DecorView 建立关联。
  3. ViewRootImpl#setView
    1. ViewRootImpl#requestLayout
    2. ViewRootImpl#scheduleTraversals
    3. ViewRootImpl.TraversalRunnable#performTraversals
      1. ViewRootImpl#performMeasure
      2. ViewRootImpl#performLayout
      3. ViewRootImpl#performDraw

4.2 理解MeasureSpec

4.2.1 MeasureSpec

  • 测量规格,决定View的大小。
  • 是一个32位int值,高2位代表SpecMode(测量模式),低30位代表SpecSize(规格大小)。
  • 三种模式:
    • UNSPECIFIED 父容器不对View有任何限制,要多大就给多大。常用于系统内部。
    • EXACTLY 父容器已经检测出View所需要的精确大小即SpecSize。对应LyaoutParams中的match_parent或具体数值。
    • AT_MOST 父容器为子视图指定一个最大尺寸SpecSize。对应LayoutParams中的wrap_content。
  • View的MeasureSpec由LayoutParams和父容器的LayoutParams共同决定。

4.2.2 MeasureSpec和LayoutParams的对应关系

4.3 View的工作流程

  1. 先将DecorView加载到Window中
  2. 然后开始View的绘制,调用ViewRootImpl的PerformTraversals方法
  3. performTraversals()中依次调用performMeasure()、performLayout()和performDraw()三个方法,分别完成顶级 View的绘制
  4. performMeasure() > measure() > onMeasure(),其中会实现子View的measure过程,layout和draw同理。

4.3.1 measure过程

测量View的宽高

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                         getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
  • View的Measure
    • measure() > onMeasure() > setMeasuredDimension() > getDefaultSize()
  • ViewGroup的Measure
    • measureChildren() > measureChild() > getChildMeasureSpec() > child.measure()

直接继承View的自定义View,需要重写onMeasure()并设置wrap_content时的自身大小,否则效果相当于macth_parent。

如何保证某个View已经测量完毕?

  1. Activity/View#onWindowFocusChanged
  2. view.post(runnable)
  3. ViewTreeObserver
  4. view.measure(int widthMeasureSpec , int heightMeasureSpec)

4.3.2 layout过程

确定元素的位置

  1. View的layout(),通过setFrame()来设定自己的四个顶点,确定自己的位置
  2. onLayout()确定子元素的位置,空方法,不同的ViewGroup实现不同。

4.3.3 draw过程

将View绘制到屏幕上

  1. 绘制背景(drawBackground)
  2. 绘制自己(onDraw)
  3. 绘制children(dispatchDraw)
  4. 绘制装饰(onDrawScrollBars)

4.4 自定义View

4.4.1 自定义View的分类

  1. 继承View
    • 重写onDraw
    • 支持wrap_content、处理padding
  2. 继承ViewGroup
    • 处理自身和子元素的测量和布局
  3. 继承特定的View(如TextView)
  4. 继承ViewGroup(如LinearLayout)

4.4.2 自定义View须知

  1. 尽量不要在View中使用Handler,使用post
  2. 及时停止线程和动画
  3. 滑动嵌套时,处理滑动冲突
  4. 自定义属性
    1. values下创建自定义属性的xml
    2. View的构造方法中解析自定义属性并处理
    3. 在布局中使用自定义属性

4.4.3 自定义View示例

4.4.4 自定义View的思想

2019/12/11 posted in  Android开发艺术探索

04 | JVM是如何执行方法调用的?(上)

2019/12/14 posted in  极客-深入拆解Java虚拟机

04 | 互斥锁(下):如何用一把锁保护多个资源?

  • 保护没有关联关系的多个资源
  • 保护有关联关系的多个资源
  • 使用锁的正确姿势

当我们要保护多个资源时,首先要区分这些资源是否存在关联关系。

保护没有关联关系的多个资源

银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。

用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁

保护有关联关系的多个资源

使用锁的正确姿势

用同一把锁来保护多个资源,要求锁能覆盖所有受保护资源
如何让 A 对象和 B 对象共享一把锁呢?
答:用 Account.class 作为共享的锁。Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。使用 Account.class 作为共享的锁,我们就无需在创建 Account 对象时传入了,代码更简单。

总结

相信你看完这篇文章后,对如何保护多个资源已经很有心得了,关键是要分析多个资源之间的关系

  • 如果资源之间没有关系,很好处理,每个资源一把锁就可以了。
  • 如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。
  • 除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁,这个过程可以类比一下门票管理。

关联关系如果用更具体、更专业的语言来描述的话,其实是一种“原子性”特征。“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。所以解决原子性问题,是要保证中间状态对外不可见

课后思考

在第一个示例程序里,我们用了两把不同的锁来分别保护账户余额、账户密码,创建锁的时候,我们用的是:private final Object xxxLock = new Object();,如果账户余额用 this.balance 作为互斥锁,账户密码用 this.password 作为互斥锁,你觉得是否可以呢?

答:用this.balance 和this.password 都不行。不能用可变对象做锁。
老师:一个是锁有可能会变化,另一个是 Integer 和 String 类型的对象不适合做锁。如果锁发生变化,就意味着失去了互斥功能。 Integer和String 类型的对象在 JVM 里面是可能被重用的,除此之外,JVM 里可能被重用的对象还有 Boolean,那重用意味着什么呢?意味着你的锁可能被其他代码使用,如果其他代码 synchronized(你的锁),而且不释放,那你的程序就永远拿不到锁,这是隐藏的风险。
锁,应是私有的、不可变的、不可重用的。

锁的最佳实践:

// 普通对象锁
private final Object 
  lock = new Object();
// 静态对象锁
private static final Object
  lock = new Object(); 
2019/12/05 posted in  极客-Java并发实战

04 原型模式

对象的克隆——原型模式

原型模式(Prototype Pattern)是用于创建重复的对象,同时又能保证性能。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
这种模式是实现了一个原型接口,该接口用于创建当前对象的克隆。当直接创建对象的代价比较大时,则采用这种模式。例如,一个对象需要在一个高代价的数据库操作之后被创建。我们可以缓存该对象,在下一个请求时返回它的克隆,在需要的时候更新数据库,以此来减少数据库调用。

介绍

意图:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。
主要解决:在运行期建立和删除原型。
何时使用: 1、当一个系统应该独立于它的产品创建,构成和表示时。 2、当要实例化的类是在运行时刻指定时,例如,通过动态装载。 3、为了避免创建一个与产品类层次平行的工厂类层次时。 4、当一个类的实例只能有几个不同状态组合中的一种时。建立相应数目的原型并克隆它们可能比每次用合适的状态手工实例化该类更方便一些。
如何解决:利用已有的一个原型对象,快速地生成和原型对象一样的实例。
关键代码: 1、实现克隆操作,在 JAVA 继承 Cloneable,重写 clone(),在 .NET 中可以使用 Object 类的 MemberwiseClone() 方法来实现对象的浅拷贝或通过序列化的方式来实现深拷贝。 2、原型模式同样用于隔离类对象的使用者和具体类型(易变类)之间的耦合关系,它同样要求这些"易变类"拥有稳定的接口。
应用实例: 1、细胞分裂。 2、JAVA 中的 Object clone() 方法。
优点: 1、性能提高。 2、逃避构造函数的约束。
缺点: 1、配备克隆方法需要对类的功能进行通盘考虑,这对于全新的类不是很难,但对于已有的类不一定很容易,特别当一个类引用不支持串行化的间接对象,或者引用含有循环结构的时候。 2、必须实现 Cloneable 接口。
使用场景: 1、资源优化场景。 2、类初始化需要消化非常多的资源,这个资源包括数据、硬件资源等。 3、性能和安全要求的场景。 4、通过 new 产生一个对象需要非常繁琐的数据准备或访问权限,则可以使用原型模式。 5、一个对象多个修改者的场景。 6、一个对象需要提供给其他对象访问,而且各个调用者可能都需要修改其值时,可以考虑使用原型模式拷贝多个对象供调用者使用。 7、在实际项目中,原型模式很少单独出现,一般是和工厂方法模式一起出现,通过 clone 的方法创建一个对象,然后由工厂方法提供给调用者。原型模式已经与 Java 融为浑然一体,大家可以随手拿来使用。
注意事项:与通过对一个类进行实例化来构造新对象不同的是,原型模式是通过拷贝一个现有对象生成新对象的。浅拷贝实现 Cloneable,重写,深拷贝是通过实现 Serializable 读取二进制流。
注意:拷贝所创建的是全新的对象,它们在内存中拥有新的地址,新对象的修改不会对原型对象造成任何影响,每一个克隆对象都是相互独立的。通过不同的方式对拷贝对象进行修改后,可以得到一系列相似但不完全相同的对象。

2019/11/01 posted in  菜鸟-设计模式

04 四大组件的工作过程

4.1 根Activity的启动过程

  1. Launcher请求AMS过程
  2. AMS到ApplicationThread的调用过程
  3. ActivityThread启动Activity的过程

4.1.1 Launcher请求AMS过程

4.1.2 AMS到ApplicationThread的调用过程

4.1.3 ActivityThread启动Activity的过程

4.1.4 根Activity启动过程中涉及的进程

4.2 Service的启动过程

  1. ContextImpl到AMS的调用过程
  2. ActivityThread启动Service

4.2.1 ContextImpl到AMS的调用过程

4.2.2 ActivityThread启动Service

4.3 Service的绑定过程

  1. ContextImpl到AMS的调用过程
  2. Service的绑定过程

4.3.1 ContextImpl到AMS的调用过程

4.3.2 Service的绑定过程

4.4 广播的注册、发送和接收过程

4.4.1 广播的注册过程

4.4.2 广播的发送和接收过程

  1. ContextImpl到AMS的调用过程
  2. AMS到BroadcastReceiver的调用过程。

4.5 Content Provider的启动过程

  1. query方法到AMS的调用过程
  2. AMS启动Content Provider的过程

4.5.1 query方法到AMS的调用过程

4.5.2 AMS启动Content Provider的过程

4.6 本章小结

2019/03/27 posted in  Android进阶解密

04 复杂度分析下:浅析最好、最坏、平均、均摊时间复杂度

  • 最好情况时间复杂度(best case time complexity)
  • 最坏情况时间复杂度(worst case time complexity)
  • 平均情况时间复杂度(average case time complexity)
  • 均摊时间复杂度(amortized time complexity)

最好情况时间复杂度就是,在最理想的情况下,执行这段代码的时间复杂度。
最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。

平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度,这个值是概率论中的加权平均值,也叫作期望值

均摊时间复杂度就是一种特殊的平均时间复杂度。

大部分情况下,我们并不需要区分最好、最坏、平均三种复杂度。平均复杂度只在某些特殊情况下才会用到,而均摊时间复杂度应用的场景比它更加特殊、更加有限。

2019/04/01 posted in  极客-数据结构与算法之美

04 多线程编程

4.1 线程基础

4.1.1 进程与线程

1.什么是进程
进程是操作系统结构的基础,是程序在一个数据集合上运行的过程,是系统进行资源分配和调度的基本单位。进程可以被看作程序的实体,同样,它也是线程的容器。进程就是程序的实体,是受操作系统管理的基本运行单元。
2.什么是线程
线程是操作系统调度的最小单元,也叫作轻量级进程。在一个进程中可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。
3.为何要使用多线程
在操作系统级别上来看主要有以下几个方面:

  • 使用多线程可以减少程序的响应时间,使程序具备了更好的交互性。
  • 与进程相比,线程创建和切换开销更小,同时多线程在数据共享方面效率非常高。
  • 避免多CPU或者多核计算机的资源浪费,提高CPU的利用率。
  • 使用多线程能简化程序的结构,使程序便于理解和维护。

4.1.2 线程的状态

Java线程在运行的声明周期中可能会处于6种不同的状态,这6种线程状态分别为如下所示。

  • New:新创建状态。线程被创建,还没有调用 start 方法,在线程运行之前还有一些基础工作要做。
  • Runnable:可运行状态。一旦调用start方法,线程就处于Runnable状态。一个可运行的线程可能正在 运行也可能没有运行,这取决于操作系统给线程提供运行的时间。
  • Blocked:阻塞状态。表示线程被锁阻塞,它暂时不活动。
  • Waiting:等待状态。线程暂时不活动,并且不运行任何代码,这消耗最少的资源,直到线程调度器重新激活它。
  • Timed waiting:超时等待状态。和等待状态不同的是,它是可以在指定的时间自行返回的。
  • Terminated:终止状态。表示当前线程已经执行完毕。导致线程终止有两种情况:第一种就是run方法执行完毕正常退出;第二种就是因为一个没有捕获的异常而终止了run方法,导致线程进入终止状态。

4.1.3 创建线程

多线程的实现一般有以下3种方法,其中前两种为最常用的方法。

  1. 继承Thread类,重写run()方法
  2. 实现Runnable接口,并实现该接口的run()方法
  3. 实现Callable接口,重写call()方法

Callable接口实际是属于Executor框架中的功能类,Callable接口与Runnable接口的功能类似,但提供了比Runnable更强大的功能,主要表现为以下3点:

  1. Callable可以在任务接受后提供一个返回值;
  2. Callable中的call()方法可以抛出异常;
  3. 运行Callable可以拿到一个Future对象,Future对象表示异步计算的结果,它提供了检查计算是否完成的方法。由于线程属于异步计算模型,因此无法从别的线程中得到函数的返回值,在这种情况下就可 以使用 Future 来监视目标线程调用 call()方法的情况。但调用 Future的get()方法以获取结果时,当前线程就会阻塞,直到call()方法返回结果。

4.1.4 理解中断

当线程的run方法执行完毕,或者在方法中出现没有捕获的异常时,线程将终止。

  • interrupt()方法可以用来请求中断线程。当一个线程调用 interrupt 方法时,线程的中断标识位将被置位(中断标识位为true),线程会不时地检测这个中断标识位,以判断线程是否应该被中断。
  • Thread.currentThread().isInterrupted() 判断线程是否被置位
  • Thread.interrupted(),对中断标识位进行复位。

但是如果一个线程被阻塞,就无法检测中断状态。如果一个线程处于阻塞状态,线程在检查中断标识位时如果发现中断标识位为true,则会在阻塞方法调用处抛出InterruptedException异常,并且在抛出异常前将线程的中断标识位复位,即重新设置为false。需要注意的是被中断的线程不一定会终止,中断线程是为了引起线程的注意,被中断的线程可以决定如何 去响应中断。如果是比较重要的线程则不会理会中断,而大部分情况则是线程会将中断作为一个终止的请求。

抛出InterruptedException异常后合理的处理方式:

  1. 在catch子句中,调用Thread.currentThread.interrupt()来设置中断状态(因为抛出异常后中断标识位会复位),让外界通过判断Thread.currentThread().isInterrupted()来决定是否终止线程还是继续下去。

    void my Task() {
        try {
    sleep(50)
    } catch(InterruptedException) {
    Thread.currentThread().interrupted();
    //...
    }
    }
  2. 更好的做法就是,不使用try来捕获这样的异常,让方法直接抛出,这样调用者可以捕获这个异常。

    void myTask() throw InterruptedException {
        sleep(50)
    }

4.1.5 安全地终止线程

4.2 同步

在多线程应用中,两个或者两个以上的线程需要共享对同一个数据的存取。如果两个线程存取相同的对象,并且每一个线程都调用了修改该对象的方法, 这种情况通常被称为竞争条件。比如很多人同时买火车票,如果不使用同步保证其原子性,有可能出现两个人买同一张票的情况。解决方法如下:当一个线程要使用火车票这个资源时,我们就交给它一把锁,等它把事情做完后再把锁给另一个要用这个资源的线程。这样就不会出现上述情况了。

4.2.1 重入锁与条件对象

synchronized 关键字自动提供了锁以及相关的条件。重入锁ReentrantLock是 Java SE 5.0引入的,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。用 ReentrantLock保护代码块的结构如下所示:

Lock mLock = new ReentrantLock();
mLock.lock() ;
try {
//...
} finally {
mLock.unlock() ;
}

这一结构确保任何时刻只有一个线程进入临界区,临界区就是在同一时刻只能有一个任务访问的代码区。一旦一个线程封锁了锁对象,其他任何线程都无法进入Lock语句。把解锁的操作放在finally中是十分必 要的。如果在临界区发生了异常,锁是必须要释放的,否则其他线程将会永远被阻塞。进入临界区时,却 发现在某一个条件满足之后,它才能执行。这时可以使用一个条件对象来管理那些已经获得了一个锁但是 却不能做有用工作的线程,条件对象又被称作条件变量

一个锁对象拥有多个相关的条件对象,可以用newCondition方法获得一个条件对象Condition,我们得到条件对象后调用await方法,当前线程就被阻塞了并放弃了锁。一旦一个线程调用 await 方法,它就会进入该条件的等待集并处于阻塞状态,直到另一个线程调用了同 一个条件的signalAll方法时为止。调用signalAll方法时并不是立即激活一个等待线程,它仅仅解除了等待线程的阻塞,以便这些线程能 够在当前线程退出同步方法后,通过竞争实现对对象的访问。还有一个方法是signal,它则是随机解除某个线程的阻塞。如果该线程仍然不能运行,则再次被阻塞。如果没有其他线程再次调用signal,那么系统就死锁了。

4.2.2 同步方法

如果一个方法用 synchronized 关键字声明,那么对象的锁将保护整个方法。

public synchronized void method() {
}

等价于:

Lock mLock = new ReentrantLock();
public void method() {
    mLock.lock();
    try {
    } finally {
        mLock.unlock();
    }
}

4.2.3 同步代码块

其获得了obj的锁,obj指的是一个对象。

synchronized(obj) {
}

同步代码块是非常脆弱的, 通常不推荐使用。一般实现同步最好用java.util.concurrent包下提供的类,比如阻塞队列。

4.2.4 volatile

有时仅仅为了读写一个或者两个实例域就使用同步的话,显得开销过大;而volatile关键字为实例域的同步访问提供了免锁的机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个 线程并发更新的。再讲到volatile关键字之前,我们需要了解一下内存模型的相关概念以及并发编程中的3个 特性:原子性、可见性和有序性。

1.Java内存模型

Java中的堆内存用来存储对象实例,堆内存是被所有线程共享的运行时内存区域,因此,它存在内存可见性的问题。而局部变量、方法定义的参数则不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。Java 内存模型定义了线程和主存之间的抽象关系:线程之间的共享变量存储在主存中, 每个线程都有一个私有的本地内存,本地内存中存储了该线程共享变量的副本。需要注意的是本地内存是 Java内存模型的一个抽象概念,其并不真实存在,它涵盖了缓存、写缓冲区、寄存器等区域。Java内存模型控制线程之间的通信,它决定一个线程对主存共享变量的写入何时对另一个线程可见。

线程A与线程B之间若要通信的话,必须要经历下面两个步骤:

  1. 线程A把线程A本地内存中更新过的共享变量刷新到主存中去。
  2. 线程B到主存中去读取线程A之前已更新过的共享变量。

2.原子性、可见性和有序性

  • 原子性
    • 对基本数据类型变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行完毕, 要么就不执行。一个语句含有多个操作时,就不是原子性操作。
    • java.util.concurrent.atomic 包中有 很多类使用了很高效的机器级指令(而不是使用锁)来保证其他操作的原子性。例如 AtomicInteger 类提供 了方法incrementAndGet和decrementAndGet,它们分别以原子方式将一个整数自增和自减。可以安全地使用 AtomicInteger类作为共享计数器而无须同步。另外这个包还包含AtomicBoolean、AtomicLong和 AtomicReference这些原子类,这仅供开发并发工具的系统程序员使用,应用程序员不应该使用这些类。
  • 可见性
    • 可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上就能看到。
    • 当一个共享变量被volatile修饰时,它会保证修改的值立即被更新到主存,所以对其他线程是可见的。当有其他线程需要读取该值时,其他线程会去主存中读取新值。
    • 普通的共享变量不能保证可见性,因为普通共享变量被修改之后,并不会立即被写入主存,何时被写入主存也是不确定的。当其他线程去读取该值时,此时主存中可能还是原来的旧值,这样就无法保证可见性。
  • 有序性
    • Java内存模型中允许编译器和处理器对指令进行重排序,虽然重排序过程不会影响到单线程执行的正确性,但是会影响到多线程并发执行的正确性。
    • 可以通过volatile来保证有序性;
    • 还可以通 过synchronized和Lock来保证有序性。我们知道,synchronized和Lock保证每个时刻只有一个线程执行同步代码,这相当于是让线程顺序执行同步代码,从而保证了有序性。

3.volatile关键字

当一个共享变量被volatile修饰之后,其就具备了两个含义,一个是线程修改了变量的值时,变量的新值对其他线程是立即可见的,即不同线程对这个变量进行操作时具有可见性。另一个含义是禁止使用指令重排序。

什么是重排序呢?重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。

volatile不保证操作原子性
volatile保证有序性

4.正确使用volatile关键字

synchronized关键字可防止多个线程同时执行一段代码,那么这就会很影响程序执行效率。而volatile关键字在某些情况下的性能要优于synchronized。但是要注意volatile关键字是无法替代synchronized关键字的, 因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下两个条件:

  1. 对变量的写操作不会依赖于当前值。
  2. 该变量没有包含在具有其他变量的不变式中。

使用volatile有很多种场景,这里介绍其中的两种。

  1. 状态标志

    volatile boolean shutdownRequested;
    public void shutdown () {
    shutdownRequested = true;
    }
    public void doWork() {
    while (!shutdownRequested) {
    }
    }
  2. 双重检查模式(DCL)

4.3 阻塞队列

4.3.1 阻塞队列简介

阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元 素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

1.常见阻塞场景

阻塞队列有两个常见的阻塞场景,它们分别是:

  1. 当队列中没有数据的情况下,消费者端的所有线程都会被自动阻塞(挂起),直到有数据放入队列。队列空,消费者堵塞。
  2. 当队列中填满数据的情况下,生产者端的所有线程都会被自动阻塞(挂起),直到队列中有空的位置,线程被自动唤醒。队列满,生产者堵塞。

支持以上两种阻塞场景的队列被称为阻塞队列。

2.BlockingQueue的核心方法

  • 放入数据
    • offer(anObject):表示如果可能的话,将anObject加到BlockingQueue里。即如果BlockingQueue可以容纳,则返回true,否则返回false。(本方法不阻塞当前执行方法的线程。)
    • offer(E o,long timeout,TimeUnit unit):可以设定等待的时间。如果在指定的时间内还不能往队列中加入BlockingQueue,则返回失败。
    • put(anObject):将anObject加到BlockingQueue里。如果BlockQueue没有空间,则调用此方法的线程被阻断,直到BlockingQueue里面有空间再继续。
  • 获取数据:
    • poll(time):取走BlockingQueue 里排在首位的对象。若不能立即取出,则可以等 time参数规定的时间。取不到时返回null。
    • poll(long timeout,TimeUnit unit):从BlockingQueue中取出一个队首的对象。如果在指定时间内,队列一旦有数据可取,则立即返回队列中的数据;否则直到时间超时还没有数据可取,返回失败。
    • take():取走BlockingQueue里排在首位的对象。若BlockingQueue为空,则阻断进入等待状态,直到 BlockingQueue有新的数据被加入。
    • drainTo():一次性从BlockingQueue获取所有可用的数据对象(还可以指定获取数据的个数)。通过该方法,可以提升获取数据的效率;无须多次分批加锁或释放锁。

4.3.2 Java中的阻塞队列

在Java中提供了7个阻塞队列,它们分别如下所示。

  • ArrayBlockingQueue:由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue:由链表结构组成的有界阻塞队列。
  • PriorityBlockingQueue:支持优先级排序的无界阻塞队列。
  • DelayQueue:使用优先级队列实现的无界阻塞队列。
  • SynchronousQueue:不存储元素的阻塞队列。
  • LinkedTransferQueue:由链表结构组成的无界阻塞队列。
  • LinkedBlockingDeque:由链表结构组成的双向阻塞队列。

4.3.3 阻塞队列的实现原理

4.3.4 阻塞队列的使用场景

4.4 线程池

在编程中经常会使用线程来异步处理任务,但是每个线程的创建和销毁都需要一定的开销。如果每次 执行一个任务都需要开一个新线程去执行,则这些线程的创建和销毁将消耗大量的资源;并且线程都是“各 自为政”的,很难对其进行控制,更何况有一堆的线程在执行。这时就需要线程池来对线程进行管理。在 Java 1.5中提供了Executor框架用于把任务的提交和执行解耦,任务的提交交给Runnable或者Callable,而 Executor框架用来处理任务。Executor框架中最核心的成员就是 ThreadPoolExecutor,它是线程池的核心实现 类。

4.4.1 ThreadPoolExecutor

ThreadPoolExecutor构造方法:

public ThreadPoolExecutor(
    int corePoolSize,   //核心线程数
    int maximumPoolSize,//最大线程数
    long keepAliveTime,//非核心线程超时时间
    TimeUnit unit,//keepAliveTime参数的时间单位
    BlockingQueue<Runnable> workQueue,//任务队列
    ThreadFactory threadFactory,//线程工厂,可创建新线程
    RejectedExecutionHandler handler//饱和策略
)

4.4.2 线程池的处理流程和原理

4.4.3 线程池的种类

通过直接或者间接地配置ThreadPoolExecutor的参数可以创建不同类型的ThreadPoolExecutor,其中有 4 种线程池比较常用,它们分别是 FixedThreadPool、CachedThreadPool、SingleThreadExecutor和 ScheduledThreadPool。下面分别介绍这4种线程池。

1.FixedThreadPool

FixedThreadPool 是可重用固定线程数的线程池。

FixedThreadPool就是一个有固定数量核心线程的线程池,并且这些核心线程不会被回收。当线程数超过 corePoolSize 时,就将任务存储在任务队列中;当线程池有空闲线程时,则从任务队列中去取任务执行。

2.CachedThreadPool

CachedThreadPool是一个根据需要创建线程的线程池。CachedThreadPool 比较适于大量的需要立即处理并且耗时较少的任务。

3.SingleThreadExecutor

SingleThreadExecutor是使用单个工作线程的线程池。能确保所有的任务在一个线程中按照顺序逐一执行。

4.ScheduledThreadPool

ScheduledThreadPool是一个能实现定时和周期性任务的线程池。

4.5 AsyncTask的原理

4.6 本章小结

2019/03/27 posted in  Android进阶之光

04 虚拟机性能与故障处理工具

2019/11/28 posted in  深入理解虚拟机

04对象与类

2019/03/28 posted in  Java核心技术

05 | JVM是如何执行方法调用的?(下)

2019/12/14 posted in  极客-深入拆解Java虚拟机

05 | 一不小心就死锁了,怎么办?

  • 向现实世界要答案
  • 没有免费的午餐
  • 如何预防死锁
    1. 破坏占用且等待条件
    2. 破坏不可抢占条件
    3. 破坏循环等待条件

在上一篇文章中,我们用 Account.class 作为互斥锁,来解决银行业务里面的转账问题,虽然这个方案不存在并发问题,但是所有账户的转账操作都是串行的,性能太差。

向现实世界要答案

其实用两把锁就实现了,转出账本一把,转入账本另一把。在 transfer() 方法内部,我们首先尝试锁定转出账户 this(先把转出账本拿到手),然后尝试锁定转入账户 target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。这个逻辑可以图形化为下图这个样子。

class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 锁定转出账户
    synchronized(this) {              
      // 锁定转入账户
      synchronized(target) {           
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

没有免费的午餐

相对于用 Account.class 作为互斥锁,锁定的范围太大,我们锁定两个账户范围就小多了,这样的锁,上一章我们介绍过,叫细粒度锁使用细粒度锁可以提高并行度,是性能优化的一个重要手段

使用细粒度锁是有代价的,这个代价就是可能会导致死锁

死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象

如何预防死锁

要避免死锁就需要分析死锁发生的条件,只有以下这四个条件都发生时才会出现死锁:

  1. 互斥,共享资源 X 和 Y 只能被一个线程占用;
  2. 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  3. 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥,不过其他三个条件都是有办法破坏掉的。

  1. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
  2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
  3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

1. 破坏占用且等待条件

对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。

可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,只有账本 A 和 B 都在的时候才会给账本申请者。

对应到编程领域,“同时申请”这个操作是一个临界区,我们也需要一个角色(Java 里面的类)来管理这个临界区,我们就把这个角色定为 Allocator。它有两个重要功能,分别是:同时申请资源 apply() 和同时释放资源 free()。

账户 Account 类里面持有一个 Allocator 的单例(必须是单例,只能由一个人来分配资源)。

当账户 Account 在执行转账操作的时候:

  1. 首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;
  2. 当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。

具体的代码实现如下:

class Allocator {
  private List<Object> als =
    new ArrayList<>();
  // 一次性申请所有资源
  synchronized boolean apply(
    Object from, Object to){
    if(als.contains(from) ||
         als.contains(to)){
      return false;  
    } else {
      als.add(from);
      als.add(to);  
    }
    return true;
  }
  // 归还资源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
  }
}

class Account {
  // actr应该为单例
  private Allocator actr;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 一次性申请转出账户和转入账户,直到成功
    while(!actr.apply(this, target))
      ;
    try{
      // 锁定转出账户
      synchronized(this){              
        // 锁定转入账户
        synchronized(target){           
          if (this.balance > amt){
            this.balance -= amt;
            target.balance += amt;
          }
        }
      }
    } finally {
      actr.free(this, target)
    }
  } 
}

2. 破坏不可抢占条件

对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。

破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。

后续 java.util.concurrent 这个包下面提供的 Lock 是可以解决这个问题的。

3. 破坏循环等待条件

对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

这个实现非常简单,我们假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如下面代码中,①~⑥处的代码对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。

class Account {
  private int id;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    Account left = this        ①
    Account right = target;    ②
    if (this.id > target.id) { ③
      left = target;           ④
      right = this;            ⑤
    }                          ⑥
    // 锁定序号小的账户
    synchronized(left){
      // 锁定序号大的账户
      synchronized(right){ 
        if (this.balance > amt){
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

总结

我们今天这一篇文章主要讲了用细粒度锁来锁定多个资源时,要注意死锁的问题。这个就需要你能把它强化为一个思维定势,遇到这种场景,马上想到可能存在死锁问题。当你知道风险之后,才有机会谈如何预防和避免,因此,识别出风险很重要。

预防死锁主要是破坏三个条件中的一个,有了这个思路后,实现就简单了。但仍需注意的是,有时候预防死锁成本也是很高的。例如上面转账那个例子,我们破坏占用且等待条件的成本就比破坏循环等待条件的成本高,破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));方法,不过好在 apply() 这个方法基本不耗时。 在转账这个例子中,破坏循环等待条件就是成本最低的一个方案。

所以我们在选择具体方案的时候,还需要评估一下操作成本,从中选择一个成本最低的方案。

课后思考

我们上面提到:破坏占用且等待条件,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));这个方法,那它比 synchronized(Account.class) 有没有性能优势呢?

synchronized(Account.class) 锁了Account类相关的所有操作。while死循环的方式只锁定了当前操作的两个相关的对象。两种影响到的范围不同。
while循环可以加个timeout,避免一直阻塞下去。

2019/12/05 posted in  极客-Java并发实战

05 工厂模式

工厂模式(Factory Pattern)是 Java 中最常用的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
在工厂模式中,我们在创建对象时不会对客户端暴露创建逻辑,并且是通过使用一个共同的接口来指向新创建的对象。

介绍

意图:定义一个创建对象的接口,让其子类自己决定实例化哪一个工厂类,工厂模式使其创建过程延迟到子类进行。
主要解决:主要解决接口选择的问题。
何时使用:我们明确地计划不同条件下创建不同实例时。
如何解决:让其子类实现工厂接口,返回的也是一个抽象的产品。
关键代码:创建过程在其子类执行。
应用实例: 1、您需要一辆汽车,可以直接从工厂里面提货,而不用去管这辆汽车是怎么做出来的,以及这个汽车里面的具体实现。 2、Hibernate 换数据库只需换方言和驱动就可以。
优点: 1、一个调用者想创建一个对象,只要知道其名称就可以了。 2、扩展性高,如果想增加一个产品,只要扩展一个工厂类就可以。 3、屏蔽产品的具体实现,调用者只关心产品的接口。
缺点:每次增加一个产品时,都需要增加一个具体类和对象实现工厂,使得系统中类的个数成倍增加,在一定程度上增加了系统的复杂度,同时也增加了系统具体类的依赖。这并不是什么好事。
使用场景: 1、日志记录器:记录可能记录到本地硬盘、系统事件、远程服务器等,用户可以选择记录日志到什么地方。 2、数据库访问,当用户不知道最后系统采用哪一类数据库,以及数据库可能有变化时。 3、设计一个连接服务器的框架,需要三个协议,"POP3"、"IMAP"、"HTTP",可以把这三个作为产品类,共同实现一个接口。
注意事项:作为一种创建类模式,在任何需要生成复杂对象的地方,都可以使用工厂方法模式。有一点需要注意的地方就是复杂对象适合使用工厂模式,而简单对象,特别是只需要通过 new 就可以完成创建的对象,无需使用工厂模式。如果使用工厂模式,就需要引入一个工厂类,会增加系统的复杂度。

2019/11/01 posted in  菜鸟-设计模式

05 数组:为什么很多编程语言中数组都从0开始编号?

如何实现随机访问?

什么是数组?数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

线性表

线性表(Linear List),顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组,链表、队列、栈等也是线性表结构。

而与它相对立的概念是非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。

连续的内存空间和相同类型的数据

数组支持随机访问根据下标随机访问的时间复杂度为 O(1)。

低效的“插入”和“删除”

插入操作:

数组有序:

  • 如果在数组的末尾插入元素,需要移动数据,时间复杂度为 O(1)。
  • 如果在数组的开头插入元素,所有的数据都需要依次往后移动一位,最坏时间复杂度是 O(n)。

数组无序:

  • 直接将第k位的数据搬移到数组元素的最后;
  • 把新的元素直接放入第k个位置。

删除操作:

跟插入数据类似,如果我们要删除第 k 个位置的数据,为了内存的连续性,也需要搬移数据。

  • 如果删除数组末尾的数据,则最好情况时间复杂度为 O(1);
  • 如果删除开头的数据,则最坏情况时间复杂度为 O(n);
  • 平均情况时间复杂度也为 O(n)。

实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢?

我们继续来看例子。数组 a[10] 中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。

为了避免 d,e,f,g,h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。

如果你了解 JVM,你会发现,这不就是 JVM 标记清除垃圾回收算法的核心思想吗?没错,数据结构和算法的魅力就在于此,很多时候我们并不是要去死记硬背某个数据结构或者算法,而是要学习它背后的思想和处理技巧,这些东西才是最有价值的。

警惕数组的访问越界问题

在 C 语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的。

访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误。这种情况下,一般都会出现莫名其妙的逻辑错误。

Java会做越界检查,抛出 java.lang.ArrayIndexOutOfBoundsException。

容器能否完全替代数组?

ArrayList的优势:

  1. 就是可以将很多数组操作的细节封装起来,如数组插入、删除数据时的数据搬移操作。
  2. 支持动态扩容
    1. 存储空间不够的时候,空间自动扩容为 1.5 倍大小。
    2. 扩容操作涉及内存申请和数据搬移,比较耗时,如果能事先确定数据大小,最好创建时指定。

以下情况用数组会更合适些:

  1. 存储基本类型。比如 int、long,需要封装为 Integer、Long 类;而拆箱、装箱有一定的性能消耗,所以如果特别关注性能,就可以选用数组。
  2. 数据大小事先已知,数据操作非常简单,用不到ArrayList提供的大部分方法,也可以直接使用数组。
  3. 多维数组。

总结一下,对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。

解答开篇

为什么大多数编程语言中,数组要从 0 开始编号,而不是从 1 开始呢?

答:

  1. 减少内存寻址运算。

    1. “下标”最确切的定义应该是“偏移”
    2. 如果下标为0,a[k]的内存地址计算公式为:

      a[k]_address = base_address + k * type_size

    3. 如果下标为1,a[k]的内存地址计算公式为:

      a[k]_address = base_address +(k-1)*type_size

    4. 如果从1开始编号,每次随机访问数组元素都多了一次减法运算。

  2. 历史原因。

    1. C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言。
    2. 在一定程度上减少 C 语言程序员学习 Java 的学习成本。
2019/04/01 posted in  极客-数据结构与算法之美

05 理解RemoteViews

2020/01/14 posted in  Android开发艺术探索

05 理解上下文Context

5.1 Context的关联类

从图中我们可以看出,ContextImpl和ContextWrapper继承自Context,ContextWrapper内部包含有Context类型的mBase对象,mBase具体指向的是ContextImpl。ContextImpl提供了很多功能,但是外界需要使用并拓展ContextImpl的功能,因此设计上使用了装饰模式,ContextWrapper是装饰类,它对ContextImpl进行包装,ContextWrapper主要是起了方法传递作用,ContextWrapper中几乎所有的方法实现都是调用ContextImpl的相应方法来实现的。ContextThemeWrapper、Service和Application都继承自ContextWrapper,这样他们都可以通过mBase来使用Context的方法,同时它们也是装饰类,在ContextWrapper的基础上又添加了不同的功能。ContextThemeWrapper中包含和主题相关的方法(比如: getTheme方法),因此,需要主题的Activity继承ContextThemeWrapper,而不需要主题的Service则继承ContextWrapper。

Context的关联类采用了装饰模式,主要有以下的优点:

  • 使用者(比如Service) 能够更方便地使用Context。
  • 如果ContextImpl发生了变化,它的装饰类ContextWrapper不需要做任何修改。
  • ContextImpl 的实现不会暴露给使用者,使用者也不必关心ContextImpl的实现。●通过组合而非继承的方式,拓展ContextImpl的功能,在运行时选择不同的装饰类,实现不同的功能。

5.2 Application Context的创建过程

5.3 Application Context的获取过程

5.4 Activity的Context创建过程

5.5 Service的Context创建过程

5.6 本章小结

2019/03/27 posted in  Android进阶解密

05 网络编程与网络框架

5.1 网络分层

网络分层就是将网络节点所要完成的数据的发送或转发、打包或拆包,以及控制信息的加载或拆出等 工作,分别由不同的硬件和软件模块来完成。这样可以将通信和网络互联这一复杂的问题变得较为简单。 网络分层有不同的模型,有的模型分7层,有的模型分5层。这里介绍分5层的,因为它更好理解。网络分层 的每一层都是为了完成一种功能而设的。为了实现这些功能,就需要遵守共同的规则,这个规则叫作“协议”。

如图5-1所示,网络分层从上到下分别是应用层、传输层、网络层、数据链路层和物理层。越靠下的层越接近硬件。接下来我们从下而上来分别了解这些分层。

  1. 物理层
    该层负责比特流在节点间的传输,即负责物理传输。该层的协议既与链路有关,也与传输介质有关。 其通俗来讲就是把计算机连接起来的物理手段。
  2. 数据链路层
    该层控制网络层与物理层之间的通信,其主要功能是如何在不可靠的物理线路上进行数据的可靠传递。为了保证传输,从网络层接收到的数据被分割成特定的可被物理层传输的帧。帧是用来移动数据的结 构包,它不仅包括原始数据,还包括发送方和接收方的物理地址以及纠错和控制信息。其中的地址确定了 帧将发送到何处,而纠错和控制信息则确保帧无差错到达。如果在传送数据时,接收点检测到所传数据中 有差错,就要通知发送方重发这一帧。
  3. 网络层
    该层决定如何将数据从发送方路由到接收方。网络层通过综合考虑发送优先权、网络拥塞程度、服务质量以及可选路由的花费来决定从一个网络中的节点 A 到另一个网络中节点 B 的最佳路径。
  4. 传输层
    该层为两台主机上的应用程序提供端到端的通信。相比之下,网络层的功能是建立主机到主机的通 信。传输层有两个传输协议:TCP(传输控制协议)和UDP(用户数据报协议)。其中,TCP是一个可靠的 面向连接的协议,UDP是不可靠的或者说无连接的协议。
  5. 应用层
    应用程序收到传输层的数据后,接下来就要进行解读。解读必须事先规定好格式,而应用层就是规定 应用程序的数据格式的。它的主要协议有HTTP、FTP、Telnet、SMTP、POP3等。

5.2 TCP的三次握手与四次挥手

通常我们进行HTTP连接网络的时候会进行TCP的三次握手,然后传输数据,之后再释放连接。

TCP三次握手的过程如下。

  • 第一次握手:建立连接。客户端发送连接请求报文段,将SYN设置为1、Sequence Number (seq)为x;接下来客户端进入SYN_SENT状态,等待服务端的确认。
  • 第二次握手:服务器收到客户端的SYN报文段,对SYN报文段进行确认,设置AcknowledgmentNumber (ACK)为x+1 (seq+1) ;同时自己还要发送SYN请求信息,将SYN设置为1、seq为y。 服务端将上述所有信息放到SYN+ACK报文段中,一并发送给客户端,此时服务端进入SYN_RCVD状态。
  • 第三次握手:客户端收到服务端的SYN+ACK报文段;然后将ACK设置为y+1,向服务端发送ACK报文段,这个报文段发送完毕后,客户端和服务端都进入ESTABLISHED (TCP连 接成功)状态,完成TCP的三次握手。

当客户端和服务端通过三次握手建立了TCP连接以后,当数据传送完毕,断开连接时就需要进行TCP的四次挥手。其四次挥手如下所示。

  • 第一次挥手:客户端设置seq和ACK, 向服务端发送一个FIN报文段。此时,客户端进入FIN_WAIT_1状态,表示客户端没有数据要发送给服务端了。
  • 第二次挥手:服务端收到了客户端发送的FIN报文段,向客户端回了一个ACK报文段。
  • 第三次挥手:服务端向客户端发送FIN报文段,请求关闭连接,同时服务端进入LAST_ACK状态。
  • 第四次挥手:客户端收到服务端发送的FIN报文段,向服务端发送ACK报文段,然后客户端进入TIME_WAIT状态。服务端收到客户端的ACK报文段以后,就关闭连接。此时,客户端等待2MSL (最大报文段生存时间)后依然没有收到回复,则说明服务端已正常关闭,这样客户端也可以关闭连接了。

如果有大量的连接,每次在连接、关闭时都要经历三次握手、四次挥手,这很显然会造成性能低下。 因此,HTTP有一种叫作keepalive connections的机制,它可以在传输数据后仍然保持连接,当客户端需要再次获取数据时,直接使用刚刚空闲下来的连接而无须再次握手。

5.3 HTTP协议原理

5.3.1 HTTP简介

HTTP 是一个属于应用层的面向对象的协议,由于其简捷、快速的方式,适用于分布式超媒体信息系统。

1.HTTP的历史版本

  • HTTP 0.9:1991年发布的第一个版本,只有一个命令GET,服务器只能回应HTML格式的字符串。
  • HTTP 1.0:1996年发布的版本,内容量大大增加。除了GET命令外,还引入了POST命令和HEAD命 令。HTTP请求和回应的格式除了数据部分,每次通信都必须包括头信息,用来描述一些元数据。
  • HTTP 1.1:1997发布的版本,进一步完善了HTTP协议,直到现在还是最流行的版本。
  • SPDY协议:2009年谷歌为了解决 HTTP 1.1效率不高的问题而自行研发的协议。
  • HTTP 2:2015年新发布的版本,SPDY 协议的主要特性也在此版本中。

2.HTTP协议的主要特点

  • 支持C/S(客户/服务器)模式。
  • 简单快速:客户向服务器请求服务时,只需传送请求方法和路径。请求方法常用的有GET、HEAD、 POST,每种方法规定了客户与服务器联系的类型不同。由于 HTTP 协议简单,使得HTTP服务器的程序规模小,因而通信速度很快。
  • 灵活:HTTP允许传输任意类型的数据对象。正在传输的类型由Content-Type加以标记。
  • 无连接:无连接的含义是限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。
  • 无状态:HTTP协议是无状态协议,无状态是指协议对于事务处理没有记忆能力。缺少状态意味着如 果后续处理需要前面的信息,则它必须重传,这样可能导致每次连接传送的数据量增大;而另一方面,在服务器不需要先前信息时它的应答速度就较快。

HTTP URL的格式如下所示:http://host[":"port][abs_path]

  • http表示要通过HTTP协议来定位网络资源;
  • host表示合法的Internet主机域名或者IP地址;
  • port指定一个 端口号,为空则使用默认端口80;
  • abs_path指定请求资源的URI(Web上任意的可用资源)。

HTTP有两种报 文,分别是请求报文和响应报文,下面先来查看请求报文。

5.3.2 HTTP请求报文

HTTP 报文是面向文本的,报文中的每一个字段都是一些ASCII码串,各个字段的长度是不确定的。一 般一个HTTP请求报文由请求行、请求报头、空行和请求数据4个部分组成。

  1. 请求行
    请求行由请求方法、URL字段和HTTP协议的版本组成,格式如下:
    Method Request-URI HTTP-Version CRLF
    其中 Method表示请求方法;Request-URI是一个统一资源标识符;HTTP-Version表示请求的HTTP协议版本;CRLF表示回车和换行(除了作为结尾的CRLF外,不允许出现单独的CR或LF字符)。
    HTTP请求方法有8种,分别是GET、POST、HEAD、PUT、DELETE、TRACE、CONNECT、 OPTIONS。对于移动开发最常用的就是GET和POST了。

  2. 请求报头
    在请求行之后会有0个或者多个请求报头,每个请求报头都包含一个名字和一个值,它们之间用英文冒 号“:”分割。关于请求报头,我们会在后面做统一解释。

  3. 请求数据
    请求数据不在GET方法中使用,而在POST方法中使用。POST方法适用于需要客户填写表单的场合,与请求数据相关的最常用的请求报头是Content-Type和Content-Length。

5.3.3 HTTP响应报文

响应报文的一般格式:

HTTP 的响应报文由状态行、响应报头、空行、响应正文组成。响应正文是服务器返回的资源的内容。我们先来看看状态行。
状态行格式如下所示:
HTTP-Version Status-Code Reason-Phrase CRLF
HTTP-Version表示服务器HTTP协议的版本;Status-Code表示服务器发回的响应状态码;Reason- Phrase表示状态码的文本描述。状态码由3位数字组成,第一个数字定义了响应的类别,且有以下5种可能取值。

  • 100~199:指示信息,收到请求,需要请求者继续执行操作。 * 200~299:请求成功,请求已被成功接收并处理。
  • 300~399:重定向,要完成请求必须进行更进一步的操作。
  • 400~499:客户端错误,请求有语法错误或请求无法实现。
  • 500~599:服务器错误,服务器不能实现合法的请求。

常见的状态码如下。

  • 200 OK:客户端请求成功。
  • 400 Bad Request:客户端请求有语法错误,服务器无法理解。
  • 401 Unauthorized:请求未经授权,这个状态码必须和WWW-Authenticate报头域一起使用。
  • 403 Forbidden:服务器收到请求,但是拒绝提供服务。
  • 500 Internal Server Error:服务器内部错误,无法完成请求。
  • 503 Server Unavailable:服务器当前不能处理客户端的请求,一段时间后可能恢复正常。

5.3.4 HTTP的消息报头

消息报头分为通用报头、请求报头、响应报头、实体报头等。消息报头由键值对组成,每行一对,关键字和值用英文冒号“:”分隔。

  1. 通用报头
    它既可以出现在请求报头,也可以出现在响应报头中,如下所示。
    • Date:表示消息产生的日期和时间。
    • Connection:允许发送指定连接的选项。例如指定连接是连续的;或者指定“close”选项,通知服务器,在响应完成后,关闭连接。
    • Cache-Control:用于指定缓存指令,缓存指令是单向的(响应中出现的缓存指令在请求中未必会出现),且是独立的(一个消息的缓存指令不会影响另一个消息处理的缓存机制)。
  2. 请求报头
    请求报头通知服务器关于客户端请求的信息。典型的请求报头如下所示。
    • Host:请求的主机名,允许多个域名同处一个IP地址,即虚拟主机。
    • User-Agent:发送请求的浏览器类型、操作系统等信息。
    • Accept:客户端可识别的内容类型列表,用于指定客户端接收哪些类型的信息。 * Accept-Encoding:客户端可识别的数据编码。
    • Accept-Language:表示浏览器所支持的语言类型。
    • Connection:允许客户端和服务器指定与请求/响应连接有关的选项。例如,这时为Keep-Alive则表示
      保持连接。
    • Transfer-Encoding:告知接收端为了保证报文的可靠传输,对报文采用了什么编码方式。
  3. 响应报头
    用于服务器传递自身信息的响应。常见的响应报头如下所示。
    • Location:用于重定向接收者到一个新的位置,常用在更换域名的时候。
    • Server:包含服务器用来处理请求的系统信息,与User-Agent请求报头是相对应的。
  4. 实体报头
    实体报头用来定义被传送资源的信息,其既可用于请求也可用于响应。请求和响应消息都可以传送一 个实体。常见的实体报头如下所示。
    • Content-Type:发送给接收者的实体正文的媒体类型。
    • Content-Lenght:实体正文的长度。
    • Content-Language:描述资源所用的自然语言。
    • Content-Encoding:实体报头被用作媒体类型的修饰符。它的值指示了已经被应用到实体正文的附加 内容的编码,因而要获得Content-Type报头域中所引用的媒体类型,必须采用相应的解码机制。
    • Last-Modified:实体报头用于指示资源的最后修改日期和时间。 * Expires:实体报头给出响应过期的日期和时间。

5.3.5 抓包应用举例

5.4 HttpClient与HttpURLConnection

5.4.1 HttpClient

5.4.2 HttpURLConnection

5.5 解析Volley

适合进行数据量不大但通信频繁的网络操作。

5.5.1 Volley基本用法

1.Volley网络请求队列
2.StringRequest的用法
3.JsonRequest的用法
4.使用lmageRequest加载图片
5.使用lmageLoader加载图片
6.使用NetworklmageView加载图片
7.NetworklmageView

5.5.2 源码解析Volley

1.从RequestQueue入手
2.CacheDispatcher缓存调度线程
3.NetworkDispatcher网络调度线程

5.6 解析OkHttp

5.6.1 OkHttp基本用法

基本步骤就是创建OkHttpClient、Request和Call,最后调用Call的异步方法enqueue()或同步方法execute()。

5.6.2 源码解析OkHttp

1. OkHttp的请求网络流程

  1. 从请求处理开始分析
  2. Dispatcher任务调度
  3. Interceptor拦截器
  4. 缓存策略
  5. 失败重连

2. OkHttp的复用连接池

  1. 主要变量与构造方法
  2. 缓存操作
  3. 自动回收连接
  4. 引用计数
  5. (5)小结
    可以看出连接池复用的核心就是用Deque来存储连接,通过 put、get、connectionBecameIdle和evictAll几个操作来对Deque进行操作,另外通过判断连接中的计数对象 StreamAllocation来进行自动回收连接。

5.7 解析Retrofit

Retrofit底层是基于OkHttp实现的,使用运行时注解的方式提供功能。

5.7.1 Retrofit基本用法

1.使用前的准备工作
2.Retrofit的注解分类
3.GET请求访问网络
4.POST请求访问网络
5.消息报头Header

5.7.2 源码解析Retrofit

1.Retrofit的创建过程
2.Call的创建过程
3.Call的enqueue方法

5.8 本章小结

2019/03/27 posted in  Android进阶之光

05 调优案例与实战

2019/11/28 posted in  深入理解虚拟机

05继承

2019/03/28 posted in  Java核心技术

06 Drawable

Drawable简介

可绘制对象资源是一般概念,是指可在屏幕上绘制的图形,以及可以使用 getDrawable(int) 等 API 检索或者应用到具有 android:drawableandroid:icon2 等属性的其他 XML 资源的图形。

详情见官方文档

Drawable分类

BitmapDrawable

表示一张图片

bitmap
    |- src="@drawable/res_id"
    |- antialias="[true | false]"
    |- dither="[true | false]"
    |- filter="[true | false]"
    |- tileMode="[disabled | clamp | repeat | mirror]"
    |- gravity="[top | bottom | left | right | center_vertical |
    |            fill_vertical | center_horizontal | fill_horizontal |
    |            center | fill | clip_vertical | clip_horizontal]"

NinePatchDrawable

表示一张.9格式的图片,可自动地根据所需的宽/高进行相应的缩放并保证不失真。

nine-patch
    |- src="@drawable/9_png_resid"
    |- dither="[true | false]"

ShapeDrawable

可表示纯色、有渐变效果的基础几何图形(矩形,圆形,线条等)。

<shape
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="[rectangle | oval | line | ring]"
    <corners
        android:radius="integer"
        android:topLeftRaidus="integer"
        android:topRightRaidus="integer"
        android:bottomLeftRaidus="integer"
        android:bottomRightRaidus="integer" />
    <gradient
        android:angle="integer"
        android:centerX="integer"
        android:centerY="integer"
        android:centerColor="color"
        android:endColor="color"
        android:gradientRadius="integer"
        android:startColor="color"
        android:type="[linear | radial | sweep]"
        android:useLevel="[true | false]" />
    <padding
        android:left="integer"
        android:top="integer"
        android:right="integer"
        android:bottom="integer" />
    <size
        android:width="integer"
        android:height="integer" />
    <solid
        android:color="color" />
    <stroke
        android:width="integer"
        android:color="color"
        android:dashWidth="integer"
        android:dashGap="integer" />

LayerDrawable

表示一种层次化的Drawable集合,通过将不同的Drawable放置在不同的层上面从而达到一种叠加后的效果。

layer-list
    |- item
    |    |- drawable="@drawable/drawable_id"
    |    |- id="@+id/xxx_id"
    |    |- top="dimension"
    |    |- left="dimension"
    |    |- right="dimension"
    |    |- bottom="dimension"

StateListDrawable

表示一个Drawable的集合,每个Drawable对应着View的一种状态。

selector
    |-constantSize="[true | false]"
    |-dither="[true | false]"
    |-variablePadding="[true | false]"
    |- item
    |    |- drawable="@drawable/drawable_id"
    |    |- state_pressed="[true | false]"
    |    |- state_focused="[true | false]"
    |    |- state_selected="[true | false]"
    |    |- state_hovered="[true | false]"
    |    |- state_checked="[true | false]"
    |    |- state_checkable="[true | false]"
    |    |- state_enabled="[true | false]"
    |    |- state_activated="[true | false]"
    |    |- state_window_focused="[true | false]"

LevelListDrawable

表示一个Drawable集合,集合中的每个Drawable都有一个等级的概念。

level-list
    |- item
    |    |- drawable="@drawable/drawable_id"
    |    |- maxLevel="integer"
    |    |- minlevel="integer"

TransitionDrawable

LayerDrawable的子类,实现两层 Drawable之间的淡入淡出效果。

transition
    |- item
    |    |- drawable="@drawable/drawable_id"
    |    |- id="@+id/xxx_id"
    |    |- top="dimension"
    |    |- left="dimension"
    |    |- right="dimension"
    |    |- bottom="dimension"

InsetDrawable

表示把一个Drawable嵌入到另外一个Drawable的内部,并在四周留一些间距。

inset
    |- drawable="@drawable/drawable_id"
    |- visible="[true | false]"
    |- insetTop="dimension"
    |- insetLeft="dimension"
    |- insetRight="dimension"
    |- insetBottom="dimension"

ScaleDrawable

表示将Drawable缩放到一定比例。

scale
    |- drawable="@drawable/drawable_id"
    |- scaleGravity="[top | bottom | left | right |
        center_vertical | center_horizontal | center |
        fill_vertical | fill_horizontal | fill |
        clip_vertical | clip_horizontal]"
    |- scaleWidth="percentage"
    |- scaleHeight="percentage"

ClipDrawable

表示裁剪一个Drawable。

scale
    |- drawable="@drawable/drawable_id"
    |- gravity="[top | bottom | left | right |
        center_vertical | center_horizontal | center |
        fill_vertical | fill_horizontal | fill |
        clip_vertical | clip_horizontal]"
    |- clipOrientation="[vertical | horizontal]"
2019/03/06 posted in  Android开发艺术探索

06 | JVM是如何处理异常的?

2019/12/14 posted in  极客-深入拆解Java虚拟机

06 | 用“等待-通知”机制优化循环等待

  • 完美的就医流程
  • 用 synchronized 实现等待 - 通知机制
  • 小试牛刀:一个更好地资源分配器
  • 尽量使用 notifyAll()

完美的就医流程

一个完整的等待 - 通知机制:

  1. 线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;
  2. 当要求的条件满足时,通知等待的线程,重新获取互斥锁。

用 synchronized 实现等待 - 通知机制

在 Java 语言里,等待 - 通知机制可以有多种实现方式,比如 Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现。

在下面这个图里,左边有一个等待队列,同一时刻,只允许一个线程进入 synchronized 保护的临界区(这个临界区可以看作大夫的诊室),当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待(相当于患者分诊等待)。这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。

在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。如上图所示,当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。

那线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是 Java 对象的 notify() 和 notifyAll() 方法。当条件满足时调用 notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过

为什么说是曾经满足过呢?因为 notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。

小试牛刀:一个更好地资源分配器

等待 - 通知机制的基本原理搞清楚后,我们就来看看它如何解决一次性申请转出账户和转入账户的问题吧。在这个等待 - 通知机制中,我们需要考虑以下四个要素。

  1. 互斥锁:上一篇文章我们提到 Allocator 需要是单例的,所以我们可以用 this 作为互斥锁。
  2. 线程要求的条件:转出账户和转入账户都没有被分配过。
  3. 何时等待:线程要求的条件不满足就等待。
  4. 何时通知:当有线程释放账户时就通知。
class Allocator {
  private List<Object> als;
  // 一次性申请所有资源
  synchronized void apply(
    Object from, Object to){
    // 经典写法
    while(als.contains(from) ||
         als.contains(to)){
      try{
        wait();
      }catch(Exception e){
      }   
    } 
    als.add(from);
    als.add(to);  
  }
  // 归还资源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
    notifyAll();
  }
}

尽量使用 notifyAll()

notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。使用 notify() 有风险,它的风险在于可能导致某些线程永远不会被通知到。

总结

等待 - 通知机制是一种非常普遍的线程间协作的方式。工作中经常看到有同学使用轮询的方式来等待某个状态,其实很多情况下都可以用今天我们介绍的等待 - 通知机制来优化。Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法可以快速实现这种机制,但是它们的使用看上去还是有点复杂,所以你需要认真理解等待队列和 wait()、notify()、notifyAll() 的关系。

课后思考

很多面试都会问到,wait() 方法和 sleep() 方法都能让当前线程挂起一段时间,那它们的区别是什么?

wait与sleep区别在于:
1. wait会释放所有锁而sleep不会释放锁资源;
2. wait只能在同步方法和同步块中使用,而sleep任何地方都可以;
3. wait无需捕捉异常,而sleep需要。
两者相同点:都会让渡CPU执行时间,等待再次调度!

2019/12/05 posted in  极客-Java并发实战

06 抽象工厂模式

抽象工厂模式(Abstract Factory Pattern)是围绕一个超级工厂创建其他工厂。该超级工厂又称为其他工厂的工厂。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
在抽象工厂模式中,接口是负责创建一个相关对象的工厂,不需要显式指定它们的类。每个生成的工厂都能按照工厂模式提供对象。

介绍

意图:提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。
主要解决:主要解决接口选择的问题。
何时使用:系统的产品有多于一个的产品族,而系统只消费其中某一族的产品。
如何解决:在一个产品族里面,定义多个产品。
关键代码:在一个工厂里聚合多个同类产品。
应用实例:工作了,为了参加一些聚会,肯定有两套或多套衣服吧,比如说有商务装(成套,一系列具体产品)、时尚装(成套,一系列具体产品),甚至对于一个家庭来说,可能有商务女装、商务男装、时尚女装、时尚男装,这些也都是成套的,即一系列具体产品。假设一种情况(现实中是不存在的,要不然,没法进入共产主义了,但有利于说明抽象工厂模式),在您的家中,某一个衣柜(具体工厂)只能存放某一种这样的衣服(成套,一系列具体产品),每次拿这种成套的衣服时也自然要从这个衣柜中取出了。用 OOP 的思想去理解,所有的衣柜(具体工厂)都是衣柜类的(抽象工厂)某一个,而每一件成套的衣服又包括具体的上衣(某一具体产品),裤子(某一具体产品),这些具体的上衣其实也都是上衣(抽象产品),具体的裤子也都是裤子(另一个抽象产品)。
优点:当一个产品族中的多个对象被设计成一起工作时,它能保证客户端始终只使用同一个产品族中的对象。
缺点:产品族扩展非常困难,要增加一个系列的某一产品,既要在抽象的 Creator 里加代码,又要在具体的里面加代码。
使用场景:1、QQ 换皮肤,一整套一起换。 2、生成不同操作系统的程序。
注意事项:产品族难扩展,产品等级易扩展。

2019/11/01 posted in  菜鸟-设计模式

06 理解ActivityManagerService

6.1 AMS家族

6.1.1 Android 7.0的AMS家族

6.1.2 Android 8.0的AMS家族

6.2 AMS的启动过程

6.3 AMS与应用程序进程

AMS与应用程序进程的关系主要有以下两点:

  • 启动应用程序时AMS会检查这个应用程序需要的应用程序进程是否存在。
  • 如果需要的应用程序进程不存在,AMS就会请求Zygote进程创建需要的应用程序进程。

6.4 AMS重要的数据结构

6.4.1 解析ActivityRecord

ActivityRecord内部记录了Activity 的所有信息,因此它用来描述一个Activity, 它是在启动Activity 时被创建的,具体是在ActivityStarter 的startActivity方法中被创建的,具体可以查看4.1.2节。ActivityRecord 的部分重要成员变量如表6一1所示。

从表中可以看出ActivityRecord的作用,其内部存储了Activity 的所有信息,包括AMS的引用、AndroidManifest 节点信息、Activity 状态、Activity 资源信息和Activity 进程相关信息等,需要注意的是其中含有该ActivityRecord 所在的TaskRecord, 这就将ActivityRecord和TaskRecord关联在一起,它们是Activity 任务栈模型的重要成员,我们接着来查看TaskRecord。

6.4.2 解析TaskRecord

从表中可以发现TaskRecord的作用,其内部存储了任务栈的所有信息,包括任务栈的唯一标识符、任务栈的倾向性、任务栈中的Activity记录和AMS的引用等,需要注意的是其中含有ActivityStack,也就是当前Activity 任务栈所归属的ActivityStack,我们接着来查看ActivityStack。


6.4.3 解析ActivityStack

ActivityStack是一个管理类,用来管理系统所有Activity的各种状态,其内部维护了TaskRecord的列表,因此从Activity任务栈这一角度来说,ActivityStack也可以理解为Activity堆栈。它由ActivityStackSupervisor来进行管理的,而ActivityStackSupervisor在AMS中的构造方法中被创建。

ActivityStack的实例类型

```
public final class ActivityStackSupervisor implements DisplayListener {
   ...
    //用来存储Launcher App的所有Activity
    ActivityStack mHomeStack;

    //表示当前正在接收输入或启动下一个Activity的所有Activity
    ActivityStack mFocusedStack; 

    //表示此前接收输入的所有Activity。
    private ActivityStack mLastFocusedStack;
    ...
}
```

ActivityState

  • ActivityStack中通过枚举存储了Activity的所有的状态:

    enum ActivityState {
           INITIALIZING,
    RESUMED,
    PAUSING,
    PAUSED,
    STOPPING,
    STOPPED,
    FINISHING,
    DESTROYING,
    DESTROYED
    }

特殊状态的Activity

  • 这些特殊的状态都是ActivityRecord类型的,ActivityRecord用来记录一个Activity的所有信息。

    ActivityRecord mPausingActivity = null;//正在暂停的Activity
    ActivityRecord mLastPausedActivity = null;//上一个已经暂停的Activity
    ActivityRecord mLastNoHistoryActivity = null;//最近一次没有历史记录的Activity
    ActivityRecord mResumedActivity = null;//已经Resume的Activity
    ActivityRecord mLastStartedActivity = null;//最近一次启动的Activity
    ActivityRecord mTranslucentActivityWaiting = null;//传递给convertToTranslucent方法的最上层的Activity

维护的ArrayList

  • ActivityStack中维护了很多ArrayList,这些ArrayList中的元素类型主要有ActivityRecord和TaskRecord,其中TaskRecord用来记录Activity的Task。

6.5 Activity栈管理

Activity栈:存储和管理Activity。

6.5.1 Activity任务栈模型

  • ActivityRecord用来记录一个Activity 的所有信息
  • TaskRecord 中包含了一个或多个ActivityRecord, TaskRecord 用来表示Activity 的任务栈,用来管理栈中的ActivityRecord
  • ActivityStack又包含了一个或多个TaskRecord,它是TaskRecord的管理者。Activity 栈管理就是建立在Activity 任务栈模型之上的

有了栈管理,我们可以对应用程序进行操作,应用可以复用自身应用中以及其他应用的Activity, 节省了资源。

6.5.2 Launch Mode

  • standerd:默认模式,每次启动Activity都会创建一个新的Activity实例。
  • singleTop:如果要启动的Activity已经在栈顶,则不会重新创建Activity,同时该Activity的onNewIntent方法会被调用。如果要启动的Activity不在栈顶,则会重新创建该Activity的实例。
  • singleTask:如果要启动的Activity已经存在于它想要归属的栈中,那么不会创建该Activity实例,将栈中位于该Activity上的所有的Activity出栈,同时该Activity的onNewIntent方法会被调用。如果要启动的Activity不存在于它想要归属的栈中,并且该栈存在,则会重新创建该Activity的实例。如果要启动的Activity想要归属的栈不存在,则首先要创建一个新栈,然后创建该Activity实例并压入到新栈中。
  • singleInstance:和singleTask基本类似,不同的是启动Activity时,首先要创建在一个新栈,然后创建该Activity实例并压入新栈中,新栈中只会存在这一个Activity实例。

6.5.3 Intent的FLAG

  • FLAG_ACTIVITY_NO_HISTORY:Activity一旦退出,就不会存在于栈中。同样的,也可以在AndroidManifest.xml中设置“android:noHistory”。
  • FLAG_ACTIVITY_MULTIPLE_TASK:需要和FLAG_ACTIVITY_NEW_TASK一同使用才有效果,系统会启动一个新的栈来容纳新启动的Activity.
  • FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS:Activity不会被放入到“最近启动的Activity”列表中。
  • FLAG_ACTIVITY_BROUGHT_TO_FRONT:这个标志位通常不是由应用程序中的代码设置的,而是Launch Mode为singleTask时,由系统自动加上的。
  • FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY:这个标志位通常不是由应用程序中的代码设置的,而是从历史记录中启动的(长按Home键调出)。
  • FLAG_ACTIVITY_CLEAR_TASK:需要和FLAG_ACTIVITY_NEW_TASK一同使用才有效果,用于清除与启动的Activity相关栈的所有其他Activity。

6.5.4 taskAffinity

我们可以在AndroidManifest.xml设置android:taskAffinity,用来指定Activity希望归属的栈, 默认情况下,同一个应用程序的所有的Activity都有着相同的taskAffinity。taskAffinity在下面两种情况时会产生效果:

  • askAffinity与FLAG_ACTIVITY_NEW_TASK或者singleTask配合。如果新启动Activity的taskAffinity和栈的taskAffinity相同(栈的taskAffinity取决于根Activity的taskAffinity)则加入到该栈中。如果不同,就会创建新栈。
  • taskAffinity与allowTaskReparenting配合。如果allowTaskReparenting为true,说明Activity具有转移的能力。

6.6 本章小结

2019/03/27 posted in  Android进阶解密

06 类文件结构

2019/11/28 posted in  深入理解虚拟机

06 设计模式

6.1 设计模式六大原则

设计模式的六大原则,它们分别是单一职责原则、开放封闭原
则、里氏替换原则、依赖倒置原则、迪米特原则和接口隔离原则。

  • S 单一职责原则定义:就一个类而言,应该仅有一个引起它变化的原因。
  • O 开放封闭原则定义:类、模块、函数等应该是对于拓展是开放,对于修改是封闭。
  • L 里氏替换原则定义:所有引用基类(父类)的地方必须能透明地使用其子类的对象。
  • I 接口隔离原则定义:一个类对另一个类的依赖应该建立在最小的接口上。
  • D 依赖倒置原则定义:高层模块不应该依赖低层模块,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
  • 迪米特原则定义:一个软件实体应当尽可能少地与其他实体发生相互作用。

6.2 设计模式分类

GoF提出的设计模式总共有23种,根据目的准则分类,分为三大类。

  • 创建型设计模式,共5种:单例模式、工厂方法模式、抽象工厂模式、建造者模式、原型模式。
  • 结构型设计模式,共7种:适配器模式、装饰模式、代理模式、外观模式、桥接模式、组合模式、享 元模式。
  • 行为型设计模式,共11种:策略模式、模板方法模式、观察者模式、迭代器模式、责任链模式、命令 模式、备忘录模式、状态模式、访问者模式、中介者模式、解释器模式。

6.3 创建型设计模式

6.3.1 单例模式

6.3.2 简单工厂模式

6.3.3 工厂方法模式

6.3.4 建造者模式

6.4 结构型设计模式

6.4.1 代理模式

6.4.2 装饰模式

6.4.3 外观模式

6.4.4 享元模式

6.5 行为型设计模式

6.5.1 策略模式

6.5.2 模板方法模式

6.5.3 观察者模式

6.6 本章小结

2019/03/27 posted in  Android进阶之光

06 链表上:如何实现LRU缓存淘汰算法?

数组和链表的区别

从底层的存储结构上看一下数组链表的区别:

数组需要一块连续的内存空间来存储,对内存的要求比较高。
链表并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用。

常见的链表结构:单链表双向链表循环链表

单链表

链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。如图所示,我们把这个记录下个结点地址的指针叫作后继指针next

我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址,尾结点指针指向一个空地址NULL

链表的插入和删除操作,只需要考虑相邻结点的指针改变,所以对应的时间复杂度是O(1)。

链表要想随机访问第k个元素,需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点,需要O(n)的时间复杂度。

循环链表

循环链表是一种特殊的单链表,它的尾结点指针是指向链表的头结点

和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表。

双向链表

双向链表,它支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点。

双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。

双向链表适合解决哪种问题呢?

  • 从结构上来看,双向链表可以支持O(1)时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。

在实际的软件开发中,从链表中删除一个数据无外乎这两种情况:

  • 删除结点中“值等于某个给定值”的结点;
  • 删除给定指针指向的结点。

对于第一种情况,不管是单链表还是双向链表,都需要从头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后再通过指针操作将其删除。删除操作时间复杂度是O(1),但遍历查找的时间复杂度为O(n),所以总时间复杂度为O(n)。

对于第二种情况,因为双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历。所以,针对第二种情况,单链表删除操作需要O(n)的时间复杂度,而双向链表只需要在O(1)的时间复杂度内!

插入同理。双向链表的按值查询的效率也要比单链表高一些。

LinkedHashMap其中就用到了双向链表。

链表VS数组性能大比拼

数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对CPU缓存不友好,没办法有效预读。

链表与数组最大的区别:

  • 数组的缺点是大小固定
  • 链表本身没有大小的限制,天然地支持动态扩容。

解答开篇
如何基于链表实现LRU缓存淘汰算法?

思路:
维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。

  1. 如果此数据已经被缓存在链表中,遍历、删除这个数据对应的结点,然后再插入到链表头部。
  2. 如果此数据没有在缓存链表中,又可以分为两种情况:
    1. 缓存未满,插入到链表头部;
    2. 缓存已满,删除链表尾结点,将新数据结点插入链表头部。

因为需要遍历链表,所以时间复杂度为O(n)。

2019/04/01 posted in  极客-数据结构与算法之美

06接口、lambda表达式与内部类

2019/03/28 posted in  Java核心技术

07 Android动画深入分析

2019/03/06 posted in  Android开发艺术探索

07 | JVM是如何实现反射的?

2019/12/14 posted in  极客-深入拆解Java虚拟机

07 | 安全性、活跃性以及性能问题

  • 安全性问题
  • 活跃性问题
  • 性能问题

并发编程中我们需要注意的问题有很多,主要有三个方面,分别是:安全性问题、活跃性问题和性能问题。

安全性问题

什么是线程安全?其实本质上就是正确性,而正确性的含义就是程序按照我们期望的执行

那如何才能写出线程安全的程序呢?理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题。

那是不是所有的代码都需要认真分析一遍是否存在这三个问题呢?当然不是,其实只有一种情况需要:存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。

当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发 Bug,对此还有一个专业的术语,叫做数据竞争(Data Race)。

竞态条件(Race Condition),指的是程序的执行结果依赖线程执行的顺序。

在并发场景中,程序的执行依赖于某个状态变量,当某个线程发现状态变量满足执行条件后,开始执行操作;可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。

那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?其实这两类问题,都可以用互斥这个技术方案,而实现互斥的方案有很多,CPU 提供了相关的互斥指令,操作系统、编程语言也会提供相关的 API。从逻辑上来看,我们可以统一归为:

活跃性问题

所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”

有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”。可以类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。这种情况,基本上谦让几次就解决了,因为人会交流啊。

解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。

那“饥饿”该怎么去理解呢?所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。

解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。

那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。

性能问题

使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。

使用锁的时候一定要关注对性能的影响。 那怎么才能避免锁带来的性能问题呢?这个问题很复杂,Java SDK 并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域的性能。

不过从方案层面,我们可以这样来解决这个问题:

第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好……

第二,减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。

性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。

  • 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
  • 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
  • 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。

总结

并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。

我们在设计并发程序的时候,主要是从宏观出发,也就是要重点关注它的安全性、活跃性以及性能。安全性方面要注意数据竞争和竞态条件,活跃性方面需要注意死锁、活锁、饥饿等问题,性能方面我们虽然介绍了两个方案,但是遇到具体问题,你还是要具体分析,根据特定的场景选择合适的数据结构和算法。

课后思考

Java 语言提供的 Vector 是一个线程安全的容器,有同学写了下面的代码,你看看是否存在并发问题呢?

void addIfNotExist(Vector v, 
    Object o){
  if(!v.contains(o)) {
    v.add(o);
  }
}

答:vector是线程安全,指的是它方法单独执行的时候没有并发正确性问题,并不代表把它的操作组合在一起没有问题,而这个程序显然有老师讲的竞态条件问题。
老师:你需要将共享变量 v 封装在对象的内部,而后控制并发访问的路径,这样就能有效防止对 Vector v 变量的滥用,从而导致并发问题。

class SafeVector{
  private Vector v; 
  // 所有公共方法增加同步控制
  synchronized 
  void addIfNotExist(Object o){
    if(!v.contains(o)) {
      v.add(o);
    }
  }
}
2019/12/05 posted in  极客-Java并发实战

07 事件总线

为了简化并且更加高质量地在Activity、Fragment、Thread和Service等之间的通信,同时解决组件之间 高耦合的同时仍能继续高效地通信,事件总线设计出现了。

7.1 解析EventBus

EventBus是一款针对Android优化的发布-订阅事件总线。它简化了应用程序内各组件间、组件与后台线 程间的通信。其优点是开销小,代码更优雅,以及将发送者和接收者解耦。

7.1.1 使用EventBus

EventBus的三要素如下。

  • Event:事件。
    • 可以是任意类型的对象。
  • Subscriber:事件订阅者。
    • 在 EventBus 3.0 之前消息处理的方法只能限定于 onEvent、 onEventMainThread、onEventBackgroundThread和onEventAsync,它们分别代表4种线程模型。
    • 而在EventBus 3.0之后,事件处理的方法可以随便取名,但是需要添加一个注解@Subscribe,并且要指定线程模型(默认 为POSTING)。4种线程模型下面会讲到。
  • Publisher:事件发布者。
    • 可以在任意线程任意位置发送事件, 直接调用 EventBus 的post(Object)方法。可以自己实例化EventBus对象,但一般使用 EventBus.getDefault()就可以。根据post函数参数的类型,会自动调用订阅相应类型事件的函数。

EventBus的4种ThreadMode(线程模型)如下。

  • POSTING(默认):
    • 如果使用事件处理函数指定了线程模型为POSTING,那么该事件是在哪个线程 发布出来的,事件处理函数就会在哪个线程中运行,也就是说发布事件和接收事件在同一个线程中。在线 程模型为POSTING的事件处理函数中尽量避免执行耗时操作,因为它会阻塞事件的传递,甚至有可能会引 起ANR。
  • MAIN:
    • 事件的处理会在UI线程中执行。事件处理的时间不能太长,长了会导致ANR。
  • BACKGROUND:
    • 如果事件是在UI线程中发布出来的,那么该事件处理函数就会在新的线程中运行; 如果事件本来就是在子线程中发布出来的,那么该事件处理函数直接在发布事件的线程中执行。在此事件 处理函数中禁止进行UI更新操作。
  • ASYNC:
    • 无论事件在哪个线程中发布,该事件处理函数都会在新建的子线程中执行;同样,此事件 处理函数中禁止进行UI更新操作。

EventBus基本用法
(1)自定义一个事件类
(2)在需要订阅事件的地方注册事件
EventBus.getDefault().register(this);
(3)发送事件
EventBus.getDefault().post(messageEvent);
(4)处理事件
(5)取消事件订阅
EventBus.getDefault().unregister(this);

EventBus的黏性事件
除了上面讲的普通事件外,EventBus还支持发送黏性事件,就是在发送事件之后再订阅该事件也能收到该事件,这跟黏性广播类似。
(1)订阅者处理黏性事件 sticky = true
(2)发送黏性事件
EventBus.getDefault().postSticky(new MessageEvent ("黏性事件")) ;

7.1.2 源码解析EventBus

7.2 解析otto

7.2.1 使用otto

7.2.2 源码解析otto

2019/03/27 posted in  Android进阶之光

07 理解WindowManager

7.1 Window、WindowManager和WMS

  • Window是一个抽象类,具体的实现类为PhoneWindow,它对View进行管理。
  • WindowManager 是一个接口类,继承自接口ViewManager,它是用来管理Window的,它的实现类为WindowManagerImpl。如果我们]想要对Window (View)进行添加、更新和删除操作就可以使用WindowManager,。
  • WindowManager 会将具体的工作交由WMS来处理,WindowManager 和WMS通过Binder来进行跨进程通信,WMS作为系统服务有很多API是不会暴露给WindowManager的,这一点与ActivityManager和AMS的关系有些类似。

Window包含了View并对View进行管理,Window用虚线来表示是因为Window是一个抽象概念,用来描述一个窗口,并不是真实存在的,Window 的实体其实也是View。WindowManager用来管理Window,而WindowManager所提供的功能最终会由WMS进行处理。

7.2 WindowManager的关联类

7.3 Window的属性

7.3.1 Window的类型和显示次序

Window的类型总体来说分为三大类:

  • Application Window(应用程序窗口)
  • Sub Window(子窗口)
  • System Window(系统窗口)

应用程序窗口

public static final int FIRST_APPLICATION_WINDOW = 1;//1
public static final int TYPE_BASE_APPLICATION   = 1;//窗口的基础值,其他的窗口值要大于这个值
public static final int TYPE_APPLICATION        = 2;//普通的应用程序窗口类型
public static final int TYPE_APPLICATION_STARTING = 3;//应用程序启动窗口类型,用于系统在应用程序窗口启动前显示的窗口。
public static final int TYPE_DRAWN_APPLICATION = 4;
public static final int LAST_APPLICATION_WINDOW = 99;//2

应用程序窗口的Type值范围为1到99。

子窗口

子窗口,不能独立的存在,需要附着在其他窗口才可以,PopupWindow就属于子窗口。子窗口的Type值范围为1000到1999。

系统窗口
Toast、输入法窗口、系统音量条窗口、系统错误窗口都属于系统窗口。系统窗口的类型定义如下所示:系统窗口的类型值有接近40个,这里只列出了一小部分, 系统窗口的Type值范围为2000到2999。

窗口显示次序

当一个进程向WMS申请一个窗口时,WMS会为窗口确定显示次序。为了方便窗口显示次序的管理,手机屏幕可以虚拟的用X、Y、Z轴来表示,其中Z轴垂直于屏幕,从屏幕内指向屏幕外,这样确定窗口显示次序也就是确定窗口在Z轴上的次序,这个次序称为Z-Oder。Type值是Z-Oder排序的依据,我们知道应用程序窗口的Type值范围为1到99,子窗口1000到1999 ,系统窗口 2000到2999,,一般情况下,Type值越大则Z-Oder排序越靠前,就越靠近用户。

7.3.2 Window的标志

Window的标志也就是Flag,用于控制Window的显示,同样被定义在WindowManager的内部类LayoutParams中。

设置Window的Flag有三种方法:

  • 第一种是通过Window的addFlags方法;
  • 第二种通过Window的setFlags方法;
  • 第三种则是给LayoutParams设置Flag,并通过WindowManager的addView方法进行添加。

7.3.3 软键盘相关模式

为了使得软键盘窗口能够按照期望来显示,WindowManager的静态内部类LayoutParams中定义了软键盘相关模式:

设置SoftInputMode:

  • AndroidManifest中Activity的属性android:windowSoftInputMode;
  • Java代码getWindow().setSoftInputMode

7.4 Window的操作

对于Window的操作,最终都是交由WMS来进行处理。窗口的操作分为两大部分,一部分是WindowManager处理部分,另一部分是WMS处理部分。

7.4.1 系统窗口的添加过程

7.4.2 Activity的添加过程

  1. Activity 在启动过程中,如果Activity所在的进程不存在则会创建新的进程,创建新的进程之后就会运行代表主线程的实例ActivityThread;
  2. 当界面要与用户进行交互时,会调用ActivityThread 的handleResumeActivity方法;
  3. performResumeActivity方法最终会调用Activity 的onResume方法;
  4. 得到ViewManager类型的对象后,调用了ViewManager 的addView方法,而addView方法则是在WindowManagerImpl中实现的,addView方法的第一个参数为DecorView。

7.4.3 Window的更新过程

Window的更新过程和Window的添加过程是类似的。

  1. 调用ViewManager的updateViewLayout方法,updateViewLayout 方法在WindowManagerImpl 中实现,WindowManagerImpl的updateViewLayout方法会调用WindowManagerGlobal的updateViewI ayout方法
  2. 更新的参数设置到View中,得到要更新的窗口在View列表中的索引在ViewRootImpl列表中根据索引得到窗口的ViewRootlmpl,更新布局参数列表,调用ViewRootImpl的setLayoutParams方法将更新的参数设置到ViewRootImpl 中,ViewRootImpl的setLayoutParams 方法在最后会调用ViewRootImpl的scheduleTraversals方法。
  3. 在TraversalRunnable的run方法中调用了doTraversal方法,在doTraversal方法中又调用了performTraversals 方法,performTraversals 方法使得ViewTree开始View的工作流程:
    1. relayoutWindow方法内部会调用IWindowSession 的relayout方法来更新Window视图,最终会调用WMS的relayoutWindow方法。
    2. performTraversals 方法分别调用performMeasure、performLayout和performDraw方法,它们的内部又会调用View的measure、layout和draw方法,这样就完成了View 的工作流程。
  4. 在performTraversals方法中更新了Window视图,又执行Window中的View的工作流程,这样就完成了Window 的更新。

7.5 本章小结

2019/03/27 posted in  Android进阶解密

07 策略模式

算法的封装与切换——策略模式

在策略模式(Strategy Pattern)中,一个类的行为或其算法可以在运行时更改。这种类型的设计模式属于行为型模式。
在策略模式中,我们创建表示各种策略的对象和一个行为随着策略对象改变而改变的 context 对象。策略对象改变 context 对象的执行算法。

介绍

意图:定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。
主要解决:在有多种算法相似的情况下,使用 if...else 所带来的复杂和难以维护。
何时使用:一个系统有许多许多类,而区分它们的只是他们直接的行为。
如何解决:将这些算法封装成一个一个的类,任意地替换。
关键代码:实现同一个接口。
应用实例: 1、诸葛亮的锦囊妙计,每一个锦囊就是一个策略。 2、旅行的出游方式,选择骑自行车、坐汽车,每一种旅行方式都是一个策略。 3、JAVA AWT 中的 LayoutManager。
优点: 1、算法可以自由切换。 2、避免使用多重条件判断。 3、扩展性良好。
缺点: 1、策略类会增多。 2、所有策略类都需要对外暴露。
使用场景: 1、如果在一个系统里面有许多类,它们之间的区别仅在于它们的行为,那么使用策略模式可以动态地让一个对象在许多行为中选择一种行为。 2、一个系统需要动态地在几种算法中选择一种。 3、如果一个对象有很多的行为,如果不用恰当的模式,这些行为就只好使用多重的条件选择语句来实现。
注意事项:如果一个系统的策略多于四个,就需要考虑使用混合模式,解决策略类膨胀的问题。

2019/11/01 posted in  菜鸟-设计模式

07 虚拟机类加载机制

7.1 概述

虚拟机如何加载Class文件?Class文件中的信息进入虚拟机中会发生什么变化?

虚拟机把描述类的数据从Class文件加载到内存,并对内存进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这就是虚拟机的类加载机制。

Java语言中,类型的加载、连接和初始化过程都是在程序运行期间完成的。Java可以动态扩展语言特性就是依赖运行期动态加载和动态连接实现的。

7.2 类加载的时机

类的生命周期:加载、验证、准备、解析、初始化、使用、卸载。
验证、准备、解析三个阶段称为连接。
其中,加载、验证、准备、初始化和卸载5个阶段是确定的,解析阶段则不一定:这是为了支持Java语言的运行时绑定。

对于初始化节点,虚拟机规范严格规定了有且只有5种情况必须对类进行“初始化”(加载、验证、准备自然在此之前开始):

  1. 遇到new,getstatic,putstatic或invokestatic这4条字节码指令
  2. 使用java.lang.reflect包的方法对类进行反射调用
  3. 当初始化一个类的时候,发现其父类还没有进行过初始化,则需要先出发父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出发初始化。

以上5种触发类初始化的行为成为主动引用,除此之外,所有引用类的方式都不会触发初始化,称为被动引用。

7.3 类加载的过程

7.3.1 加载

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转换为方法区内的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

7.3.2 验证

验证是连接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

  1. 文件格式验证
    • 验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
  2. 元数据验证
    • 对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范要求。例如是否有父类、是否继承了不允许继承的类、是否实现了父类或接口中要求实现的所有方法等。
  3. 字节码验证
    • 最复杂,通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。上个阶段校验数据类型,这个分析类的方法体,确保方法运行时不会危害虚拟机。例如操作栈放置int使用时却按long加载入本地变量表中等 。
  4. 符号引用验证
    • 发生在虚拟机将符号引用转换为直接引用的时候,这个转换动作将在连接的第三个阶段——解析阶段中发生。

7.3.3 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这些变量所使用的内存都将在方法区中分配。注意:内存分配仅包括类变量(static修饰)。
如果类字段的字段属性表中存在ConstantValue属性,那么在准备阶段变量的值就会被初始化为ConstantValue属性所指定的值。

7.3.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 符号引用:以一组符号来描述所引用的目标,可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

虚拟机规范并未规定解析阶段发生的具体时间,所以虚拟机可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号进行解决,还是等到一个符号引用被使用前才解析它。

invokedynamic指令用于支持动态语言支持,即程序运行到这条指令时,解析动作才能进行。

解析动作主要针对接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

7.3.5 初始化

初始化阶段,才真正开始执行类中定义的Java程序代码。
初始化阶段,根据程序员通过程序制定的主观计划,去初始化变量和其他资源。初始化阶段是执行类构造器<clinit>()方法的过程:

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。
  • <clinit>()与类的构造函数不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。
  • 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优于子类的变量赋值操作。
  • <clinit>()方法对于类或接口并不是必须的。
  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步。

7.4 类加载器

类加载器:通过一个类的全限定名来获取描述此类的二进制字节流。

7.4.1 类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确定其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。即比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。类加载器不同,类必定不相等。

7.4.2 双亲委派模型

  • 系统类加载器
    • Bootstrap ClassLoader
    • Extensions ClassLoader
    • Application ClassLoader
  • 自定义类加载器

双亲委派模型:如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把这个请问委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终 都应该传送到顶层的启动类加载器中,只有当 父加载器反馈自己无法完成这个加载请求时(它的搜索范围中没有找到所需的类),子加载器才会尝试自己去加载。

双亲委派模型的好处:Java类随着它的类加载器一起具备了一种带有优先级的层次关系。
双亲委派模型的实现:java.lang.ClassLoader的loadClass()方法。

7.4.3 破坏双亲委派模型

  1. JDK1.2之前使用loadClass(),之后使用findClass()
  2. 基础类调用用户的代码。如JDBC。解决:线程上下文类加载器(Thread Context ClassLoader)。
  3. 用户追求动态性,如代码热替换,模块热部署等。

7.5 本章小结

2019/11/28 posted in  深入理解虚拟机

07 链表下:如何轻松写出正确的链表代码?

几个写链表代码技巧:

  1. 技巧一:理解指针或引用的含义
  2. 技巧二:警惕指针丢失和内存泄漏
  3. 技巧三:利用哨兵简化实现难度
  4. 技巧四:重点留意边界条件处理
  5. 技巧五:举例画图,辅助思考
  6. 技巧六:多写多练,没有捷径

技巧一:理解指针或引用的含义

指针:将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。

技巧二:警惕指针丢失和内存泄漏

C语言中,插入结点时,一定要注意操作的顺序;删除链表结点时,一定要记得手动释放内存空间。

技巧三:利用哨兵简化实现难度

针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。

技巧四:重点留意边界条件处理

常用来检查链表代码是否正确的边界条件有这样几个:

  • 如果链表为空时,代码是否能正常工作?
  • 如果链表只包含一个结点时,代码是否能正常工作?
  • 如果链表只包含两个结点时,代码是否能正常工作?
  • 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?

技巧五:举例画图,辅助思考

技巧六:多写多练,没有捷径

常见的链表操作:

  • 单链表反转
  • 链表中环的检测
  • 两个有序的链表合并
  • 删除链表倒数第n个结点
  • 求链表的中间结点
2019/04/01 posted in  极客-数据结构与算法之美

07异常、断言和日志

2019/03/28 posted in  Java核心技术

08 | JVM是怎么实现invokedynamic的?(上)

2019/12/14 posted in  极客-深入拆解Java虚拟机

08 | 管程:并发编程的万能钥匙

  • 什么是管程
  • MESA 模型
  • wait() 的正确姿势
  • notify() 何时可以使用

什么是管程

管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。

管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。

MESA 模型

在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和 MESA 模型。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。所以今天我们重点介绍一下 MESA 模型。

在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。

管程是如何解决互斥问题的

管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。在下图中,管程 X 将共享变量 queue 这个队列和相关的操作入队 enq()、出队 deq() 都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现;enq()、deq() 保证互斥性,只允许一个线程进入管程。

管程如何解决线程间的同步问题?

在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量 A 和条件变量 B 分别都有自己的等待队列。

条件变量和等待队列的作用是什么呢?其实就是解决线程同步问题。

public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 条件变量:队列不满  
  final Condition notFull =
    lock.newCondition();
  // 条件变量:队列不空  
  final Condition notEmpty =
    lock.newCondition();

  // 入队
  void enq(T x) {
    lock.lock();
    try {
      while (队列已满){
        // 等待队列不满 
        notFull.await();
      }  
      // 省略入队操作...
      //入队后,通知可出队
      notEmpty.signal();
    }finally {
      lock.unlock();
    }
  }
  // 出队
  void deq(){
    lock.lock();
    try {
      while (队列已空){
        // 等待队列不空
        notEmpty.await();
      }
      // 省略出队操作...
      //出队后,通知可入队
      notFull.signal();
    }finally {
      lock.unlock();
    }  
  }
}

wait() 的正确姿势

但是有一点,需要再次提醒,对于 MESA 管程来说,有一个编程范式,就是需要在一个 while 循环里面调用 wait()。这个是 MESA 管程特有的。

while(条件不满足) {
  wait();
}

notify() 何时可以使用

还有一个需要注意的地方,就是 notify() 和 notifyAll() 的使用,前面章节,我曾经介绍过,除非经过深思熟虑,否则尽量使用 notifyAll()。那什么时候可以使用 notify() 呢?需要满足以下三个条件:

  1. 所有等待线程拥有相同的等待条件;
  2. 所有等待线程被唤醒后,执行相同的操作;
  3. 只需要唤醒一个线程。

总结

Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。具体如下图所示。

Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而 Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。

并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的,所以学好管程,就是相当于掌握了一把并发编程的万能钥匙。

课后思考

wait() 方法,在 Hasen 模型和 Hoare 模型里面,都是没有参数的,而在 MESA 模型里面,增加了超时参数,你觉得这个参数有必要吗?

有hasen 是执行完,再去唤醒另外一个线程。能够保证线程的执行。hoare,是中断当前线程,唤醒另外一个线程,执行玩再去唤醒,也能够保证完成。而mesa是进入等待队列,不一定有机会能够执行。

2019/12/05 posted in  极客-Java并发实战

08 函数响应式编程

函数式编程是一种编程范式。我们常见的编程范式有命令式编程、函数式编程和逻辑式编程。我们常见的面向对象编程是一种命令式编程。命令式编程是面向计算机硬件的抽象,有变量、赋值语句、表达式和控制语句。而函数式编程是面向数学的抽象,将计算描述为一种表达式求值,函数可以在任何地方定 义,并且可以对函数进行组合。
响应式编程是一种面向数据流和变化传播的编程范式,数据更新是相关联的。把函数式编程里的一套思路和响应式编程合起来就是函数响应式编程。
函数响应式编程可以极大地简化项目,特别是处理嵌套回调的异步事件、复杂的列表过滤和变换或者 时间相关问题。在 Android 开发中使用函数响应式编程的主要有两大框架:一个是RxJava,另一个是Goodle 推出的Agera。

8.1 RxJava基本用法

8.1.1 RxJava概述

1.ReactiveX与RxJava

RxJava是ReactiveX的一种Java实现。 Rx是一个函数库,让开发者可以利用可观察序列和LINQ风格查询操作符来编写异步和基于事件的程序。开发者可以用Observables表示异步数据流,用LINQ操作符查询异步数据流,用Schedulers参数化异步数据流的并发处理。

2.为何要用RxJava

RxJava的原理就是创建一个Observable对象,然后使用各种操作符建立起来的链式操作,就如同流水线一样,把你想要处理的数据一步一步地加工成你想要的成品,然后发射给Subscriber处理。

3.RxJava与观察者模式

RxJava的异步操作是通过扩展的观察者模式来实现的。RxJava有4个角色Observable、Observer、Subscriber和Suject。Observable和 Observer 通过subscribe方法实现订阅关系,Observable就可以在需要的时候通知Observer。

8.1.2 RxJava基本实现

RxJava的基本用法分为如下3个步骤。

  1. 创建Observer(观察者) onCompleted onError onNext
  2. 创建 Observable(被观察者) create just from
  3. Subscribe(订阅)

8.1.3 RxJava的不完整定义回调

8.2 RxJava的Subject

Subject 既可以是一个 Observer 也可以是一个 Observerable,它是连接 Observer 和Observerable的桥梁。

  1. PublishSubject
  2. BehaviorSubject
  3. ReplaySubject
  4. AsyncSubject

8.3 RxJava操作符入门

8.3.1 创建操作符

  1. interval 按固定时间间隔发射整数序列
  2. range 发射指定范围的整数序列
  3. repeat 重复发射特定数据

8.3.2 变换操作符

变换操作符的作用是对Observable发射的数据按照一定规则做一些变换操作,然后将变换后的数据发射出去。

  1. map 变换
  2. flatMap 集合变换
  3. cast 转换
  4. concatMap 连续变换
  5. flatMapIterable
  6. buffer 缓存x个
  7. groupBy 分组

8.3.3 过滤操作符

过滤操作符用于过滤和选择Observable发射的数据序列,让Observable只返回满足我们条件的数据。

  1. filter 过滤
  2. elementAt 第x个
  3. distinct 去重
  4. skip 跳过
  5. take 只取x个
  6. ignoreElements 忽略
  7. throttleFirst 发射第一个
  8. throttleWithTimeOut 超时限流

8.3.4 组合操作符

组合操作符可以同时处理多个Observable来创建我们所需要的Observable。

  1. startWith 插入数据
  2. merge 合并
  3. concat 顺序合并
  4. zip 合并
  5. combineLastest

8.3.5 辅助操作符

  1. delay 延迟
  2. Do 添加回调
    • doOnEach
    • doOnNext
    • doOnSubscribe
    • doOnUnsubscribe
    • doOnCompleted
    • doOnError
    • doOnTerminate
    • finallyDo
  3. subscribeOn observeOn 指定线程
  4. timeout 超时

8.3.6 错误处理操作符

RxJava在错误出现的时候就会调用Subscriber的onError方法将错误分发出去,由Subscriber自己来处理错 误。

  1. catch
    • onErrorReturn
    • onErrorResumeNext
    • onExceptionResumeNext
  2. retry

8.3.7 条件操作符和布尔操作符

条件操作符和布尔操作符可用于根据条件发射或变换Observable,或者对它们做布尔运算。

  • 条件操作符ambdefaultIfEmptyskipUntilskipWhiletakeUntiltakeWhile等;
  • 布尔操作符allcontainsisEmptyexistssequenceEqual

8.3.8 转换操作符

转换操作符用来将 Observable 转换为另一个对象或数据结构。转换操作符有 toListtoSortedListtoMap、toMultiMap、getIterator和nest等。

8.4 RxJava的线程控制

1.内置的Scheduler
如果我们不指定线程,默认是在调用subscribe方法的线程上进行回调的。如果我们想切换线程,就需要使用Scheduler。RxJava 已经内置了如下5个Scheduler。

  • Schedulers.immediate():直接在当前线程运行,它是timeout、timeInterval和timestamp操作符的默认调度器。
  • Schedulers.newThread():总是启用新线程,并在新线程执行操作。
  • Schedulers.io():I/O操作(读写文件、读写数据库、网络信息交互等)所使用的 Scheduler。
  • Schedulers.computation():计算所使用的 Scheduler,例如图形的计算。
  • Schedulers.trampoline():当我们想在当前线程执行一个任务时,并不是立即时,可以用trampoline()将它入队。
  • AndroidSchedulers.mainThread():RxAndroid库中提供的Scheduler,它指定的操作在主线程中运行。

2.控制线程
在RxJava中用subscribeOn和observeOn操作符来控制线程。

8.5 RxJava的使用场景

8.5.1 RxJava结合OkHttp访问网络

8.5.2 RxJava结合Retrofit访问网络

8.5.3 用RxJava实现RxBus

8.6 RxJava源码解析

8.6.1 RxJava的订阅过程

8.6.2 RxJava的变换过程

8.6.3 RxJava的线程切换过程

8.7 本章小结

2019/03/27 posted in  Android进阶之光

08 栈:如何实现浏览器的前进和后退功能?

如何理解“栈”?

  • 后进者先出,先进者后出,这就是典型的“栈”结构。
  • 从栈的操作特性上来看,栈是一种“操作受限”的线性表,只允许在一端插入和删除数据。

为什么还要用这个“操作受限”的“栈”呢?

  • 特定的数据结构是对特定场景的抽象,而且,数组或链表暴露了太多的操作接口,操作上的确灵活自由,但使用时就比较不可控,自然也就更容易出错。

如何实现一个“栈”?

栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,我们叫作顺序栈,用链表实现的栈,我们叫作链式栈

顺序栈和链式栈,时间、空间复杂度都是 O(1)。

支持动态扩容的顺序栈

  1. 底层依赖一个支持动态扩容的数组
  2. 当栈满了之后,申请一个更大的数组,将原来的数据搬移到新数组中。

  • 出栈的时间复杂度仍然是 O(1)。
  • 入栈操作:当栈中有空闲空间,入栈操作的时间复杂度为 O(1);空间不够时,需要重新申请内存和数据搬移,时间复杂度为O(n)。
  • 平均情况下的耗时接近O(1)

栈在函数调用中的应用

函数调用栈操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

栈在表达式求值中的应用

  1. 编译器就是通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。
  2. 如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取2个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。

栈在括号匹配中的应用

借助栈来检查表达式中的括号是否匹配:

用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。

解答开篇

如何实现浏览器的前进、后退功能?

我们使用两个栈,X和Y,我们把首次浏览的页面依次压入栈X,当点击后退按钮时,再依次从栈X中出栈,并将出栈的数据依次放入栈Y。当我们点击前进按钮时,我们依次从栈Y中取出数据,放入栈X中。当栈X中没有数据时,那就说明没有页面可以继续后退浏览了。当栈Y中没有数据,那就说明没有页面可以点击前进按钮浏览了。

2019/04/01 posted in  极客-数据结构与算法之美

08 泛型程序设计

使用泛型机制编写的程序代码要比哪些杂乱地使用Object变量,然后在进行强制类型转换的代码具有更好的安全性和可读性。泛型对于集合类尤其有用,例如,ArrayList就是一个无处不在的集合类。

1 为什么要使用泛型程序设计

泛型程序设计(Generic programming)意味着编写的代码可以被很多不同类型的对象所重用。

1.1 类型参数的好处

不使用泛型的问题:

  1. 当获取一个值时必须进行强制类型转换。
  2. 操作时没有错误检查。

泛型提供的解决方案类型参数(type parameters),使得程序具有更好的可读性和安全性。

如ArrayList,声明时即可知道数据类型,并且添加数据时有检查,可以避免插入错误类型的数据。

1.2 谁想成为泛型程序员

泛型程序员的任务就是预测出所用类的未来可能有的所有用途。
通配符类型(wildcard type),可以帮助库的构建者编写出尽可能灵活的方法。

2 定义简单泛型类

一个泛型类(generic class)就是一个或多个类型变量的类。

public class Pair<T> {
    
    private T first;
    private T second;

    public Pair() { first = null;second = null; }

    public Pair(T first, T second) { this.first = first;this.second = second; }

    public T getFirst() { return first; }

    public void setFirst(T first) { this.first = first; }

    public T getSecond() { return second; }

    public void setSecond(T second) { this.second = second; }
}

Pair类引入了一个类型变量T,用<>括起来,并放在类型后面。泛型类可以有多个类型变量。
类定义中的 类型变量 指定 方法的返回类型 以及 域和局部变量的类型。

3 泛型方法

定义一个带有类型参数的简单方法:

class ArrayAlg {

    public static <T> T getMiddle(T... a) {
        return a[a.length / 2];
    }
}

4 类型变量的限定

有时,类或者方法需要对类型变量加以约束。解决方案是将T限制为实现某个接口。

public static <T extends Comparable> T min(T[] a)...

一个类型变量或通配符可以有多个限定:

T extends Comparable & Serializable

5 泛型代码和虚拟机

虚拟机没有泛型类型对象--所有对象都属于普通类。

5.1 类型擦除

无论何时定义一个泛型类型,都自动提供了一个相应的原始类型(raw type)。原始类型的名字就是删去类型参数后的泛型类型名。擦除(erased)类型变量,并替换为限定类型(无限定的变量用Object)。

5.2 翻译泛型表达式

当程序调用泛型方法时,如果擦除返回类型,编译器插入强制类型转换。

5.3 翻译泛型方法

类型擦除也会出现在泛型方法中。

类型擦除与多态发生冲突。解决这个问题,编译器会生成一个桥方法(bridge method)。

Java泛型转换的事实:

  • 虚拟机中没有泛型,只有普通的类和方法。
  • 所有的类型参数都用它们的限定类型替换。
  • 桥方法被合成来保存多态。
  • 为保持类型安全性,必要时插入强制类型转换。

5.4 调用遗留代码

设计Java泛型类型时,主要目标是运行泛型代码和遗留代码之间能够互操作。

6 约束与局限性

Java泛型使用时要考虑一些限制,大多数限制都是由类型擦除引起的。
(todo暂时只有介绍,没有原因说明)

6.1 不能用基本类型实例化类型参数

没有Pair<double>,只有Pair<Double>。

原因是类型擦除之后,Pair类含有Object类型的域,而Object不能存储double值。

6.2 运行时类型查询只适用于原始类型

虚拟机中的对象总有一个特定的非泛型类型。因此,所有的类型查询只产生原始类型。

if (a instanceof Pair<String>) //error
Pair<String> p = (Pair<String>) a;//Warning

Pair<T>的gatClass将返回Pair.class

6.3 不能创建参数化类型的数组

Pair<String>[] table = new Pair<String>[10];//error

如果需要收集参数化类型对象,使用ArrayList<Pair<String>>。

6.4 Varargs警告

@SuppressWarnings("unchecked")和@SafeVarargs抑制警告。

6.5 不能实例化类型变量

不能使用像new T(...),new T[...]或T.class这样的表达式中的类型变量。

6.6 不能构造泛型数组

public static <T extends Comparable> T[] minmax(T[] a) { T[] mm = new T[2]; ...}//error

6.7 泛型类的静态上下文中类型变量无效

public class Singleton<T>{
    private static T instance;  //error
    public static T getInstance(){...}  //error
}

6.8 不能抛出或捕获泛型类的实例

public class Problem<T> extends Exception {/**...**/}//error

6.9 可以消除对受查异常的检查

6.10 注意擦除后的冲突

public boolean equals(T value){...}//error

方法擦除equals(T)就是equals(Object),与Object.equals方法冲突。

7 泛型类型的继承规则

无论S与T有什么关系,通常Pair<S>与Pair<T>没有什么联系。

8 通配符类型

8.1 通配符的概念

通配符类型中,运行类型参数变化。如Pair<? extends Employee>,表示任何泛型Pair类型。

8.2 通配符的超类型限定

通配符还可以指定一个超类型限定,? super Manager
带有超类型限定的通配符可以向泛型对象写入,带有子类型限定的通配符可以从泛型对象读取。

public static <T extends Comparable<? super T>> T min(T[] a)

有可能被声明为使用类型T的对象,也有可能使用T的超类型。

8.3 无限定通配符

还可以使用无限定的通配符,如Pair<?>。

Pair<?>和Pair本质的不同在于:可以用任意Object对象调用原始Pair类的方法。它对于许多简单操作非常有用,比如hasNulls(Pair<?> p)。

8.4 通配符捕获

9 反射和泛型

9.1 泛型Class类

9.2 使用Class参数进行类型匹配

9.3 虚拟机中的泛型类型信息

2019/03/28 posted in  Java核心技术

08 状态模式

处理对象的多种状态及其相互转换——状态模式

在状态模式(State Pattern)中,类的行为是基于它的状态改变的。这种类型的设计模式属于行为型模式。
在状态模式中,我们创建表示各种状态的对象和一个行为随着状态对象改变而改变的 context 对象。

介绍

意图:允许对象在内部状态发生改变时改变它的行为,对象看起来好像修改了它的类。
主要解决:对象的行为依赖于它的状态(属性),并且可以根据它的状态改变而改变它的相关行为。
何时使用:代码中包含大量与对象状态有关的条件语句。
如何解决:将各种具体的状态类抽象出来。
关键代码:通常命令模式的接口中只有一个方法。而状态模式的接口中有一个或者多个方法。而且,状态模式的实现类的方法,一般返回值,或者是改变实例变量的值。也就是说,状态模式一般和对象的状态有关。实现类的方法有不同的功能,覆盖接口中的方法。状态模式和命令模式一样,也可以用于消除 if...else 等条件选择语句。
应用实例: 1、打篮球的时候运动员可以有正常状态、不正常状态和超常状态。 2、曾侯乙编钟中,'钟是抽象接口','钟A'等是具体状态,'曾侯乙编钟'是具体环境(Context)。
优点: 1、封装了转换规则。 2、枚举可能的状态,在枚举状态之前需要确定状态种类。 3、将所有与某个状态有关的行为放到一个类中,并且可以方便地增加新的状态,只需要改变对象状态即可改变对象的行为。 4、允许状态转换逻辑与状态对象合成一体,而不是某一个巨大的条件语句块。 5、可以让多个环境对象共享一个状态对象,从而减少系统中对象的个数。
缺点: 1、状态模式的使用必然会增加系统类和对象的个数。 2、状态模式的结构与实现都较为复杂,如果使用不当将导致程序结构和代码的混乱。 3、状态模式对"开闭原则"的支持并不太好,对于可以切换状态的状态模式,增加新的状态类需要修改那些负责状态转换的源代码,否则无法切换到新增状态,而且修改某个状态类的行为也需修改对应类的源代码。
使用场景: 1、行为随状态改变而改变的场景。 2、条件、分支语句的代替者。
注意事项:在行为受状态约束的时候使用状态模式,而且状态不超过 5 个。

2019/11/01 posted in  菜鸟-设计模式

08 理解WindowManagerService

8.1 WMS的职责

窗口管理
窗口动画
输入系统的中转站
Surface管理

8.2 WMS的创建过程

8.3 WMS的重要成员

8.4 Window的添加过程(WMS处理部分)

addWindow方法分了3个部分来进行讲解,主要就是做了下面4件事:

  1. 对所要添加的窗口进行检查,如果窗口不满足一些条件,就不会再执行下面的代码逻辑。
  2. WindowToken相关的处理,比如有的窗口类型需要提供WindowToken,没有提供的话就不会执行下面的代码逻辑,有的窗口类型则需要由WMS隐式创建WindowToken。
  3. WindowState的创建和相关处理,将WindowToken和WindowState相关联。
  4. 创建和配置DisplayContent,完成窗口添加到系统前的准备工作。

8.5 Window的删除过程

Window的删除过程:

  1. 检查删除线程的正确性,如果不正确就抛出异常。
  2. 从ViewRootImpl列表、布局参数列表和View列表中删除与V对应的元素。
  3. 判断是否可以直接执行删除操作,如果不能就推迟删除操作。
  4. 执行删除操作,清理和释放与V相关的一切资源。

8.6 本章小结

2019/03/27 posted in  Android进阶解密

08 理解Window和WindowManager

8.1 Window和WindowManager

Window表示的是一个窗口的概念,它的具体实现是PhoneWindow,创建一个Window很简单,只需要WindowManager去实现,WindowManager是外界访问Window的入口,Window的具体实现是在WindowManagerService中,他们两个的交互是一个IPC的过程,Android中的所有视图都是通过Window来实现的。

Flag参数

  • FLAG_NOT_FOCUSABLE
    • 表示窗口不需要获取焦点,也不需要接收各种事件,最终的事件会传递给下层的具体焦点的window
  • FLAG_NOT_TOUCH_MODAL
    • 在此模式下,系统会将当前window区域以外的单击事件传递给底层的Window,此前的Window区域以内的单机事件自己处理,
  • FLAG_SHOW_WHEN_LOCKED
    • 开启这个属性可以让window显示在锁屏上

Type参数表示window的类型,window有三种类型,分别是应用,子,系统,应用window对应一个Activity,子Window不能单独存在,需要依赖一个父Window。

Window是分层的,每个Window对应着z-ordered,层级大的会覆盖在层级小的Window上面,这和HTML中的z-index的概念是一致的,在这三类中,应用是层级范围是1-99,子window的层级是1000-1999,系统的层级是2000-2999。这些范围对应着type参数,如果想要window在最顶层,那么层级范围设置大一点就好了,很显然系统的值要大一些,系统的值很多,我们一般会选择TYPE_SYSTEM_OVERLAY和TYPE_SYSTEM_ERROR,还需要声明权限。

WindowManager所提供的功能很简单,常用的有三个方法,添加View,更新View,删除View,这三个方法定义在ViewManager中,而WindowManager继承自ViewManager。

    public interface ViewManager {
        public void addView(View view, ViewGroup.LayoutParams params);
        public void updateViewLayout(View view, ViewGroup.LayoutParams params);
        public void removeView(View view);
    }

我们常见的可以拖动的View,其实也很好实现,就是不断的更改他xy的位置:

    btn.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                int rawX = (int) event.getRawX();
                int rawY = (int) event.getRawY();
                switch (event.getAction()) {
                    case MotionEvent.ACTION_MOVE:
                        layout.x = rawX;
                        layout.y = rawY;
                        wm.updateViewLayout(btn,layout);
                        break;
                }
                return false;
            }
        });

8.2 Window的内部机制

Window是一个抽象的概念,每一个Window都对应着一个View和一个ViewRootlmpl,Window和View通过ViewRootImpl 来建立联系,因此Window并不是实际存在的,它是以View的形式存在。这点从WindowManager的定义也可以看出,它提供的三个接口方法addView、updateViewLayout以及removeView都是针对View的,这说明View才是Window存在的实体。在实际使用中无法直接访问Window, 对Window 的访问必须通过WindowManager。为了分析Window的内部机制,这里从Window的添加、删除以及更新说起。

8.2.1 Window的添加过程

WindowManagerImpl并没有直接去实现一个Window的三大操作,而是全部交给了WindowManagerGlobal来处理,WindowManagerGlobal是一个工厂的性质提供自己的实现。WindowManagerImpl这种工作模式就是典型的桥接模式,将所有的操作全部委托给WindowManagerGlobal去实现,WindowManagerGlobal的addView方法主要分如下几步:

  1. 检查参数是否合法,如果是子Window还需要调整一下参数
  2. 创建ViewRootImpl并将View添加到列表中
  3. 通过ViewRootImpl来更新界面并完成Window的添加

8.2.2 Window的删除过程

8.2.3 Window的更新过程

8.3 Window的创建过程

8.3.1 Activity的Window创建过程

8.3.2 Dialog的Window创建过程

8.3.3 Toast的Window创建过程

2019/03/06 posted in  Android开发艺术探索

08 虚拟机字节码执行引擎

2019/11/28 posted in  深入理解虚拟机

09 JNI原理

2019/03/27 posted in  Android进阶解密

09 | JVM是怎么实现invokedynamic的?(下)

2019/12/14 posted in  极客-深入拆解Java虚拟机

09 | Java线程(上):Java线程的生命周期

  • 通用的线程生命周期
  • Java 中线程的生命周期
    1. RUNNABLE 与 BLOCKED 的状态转换
    2. RUNNABLE 与 WAITING 的状态转换
    3. RUNNABLE 与 TIMED_WAITING 的状态转换
    4. 从 NEW 到 RUNNABLE 状态
    5. 从 RUNNABLE 到 TERMINATED 状态

通用的线程生命周期

通用的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态。

这“五态模型”的详细情况如下所示:

  1. 初始状态,指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。
  2. 可运行状态,指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配 CPU 执行。
  3. 当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到 CPU 的线程的状态就转换成了运行状态
  4. 运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。
  5. 线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。

Java 中线程的生命周期

Java 语言中线程共有六种状态,分别是:

  1. NEW(初始化状态)
  2. RUNNABLE(可运行 / 运行状态)
  3. BLOCKED(阻塞状态)
  4. WAITING(无时限等待)
  5. TIMED_WAITING(有时限等待)
  6. TERMINATED(终止状态)

在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权

1. RUNNABLE 与 BLOCKED 的状态转换

只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。

2. RUNNABLE 与 WAITING 的状态转换

总体来说,有三种场景会触发这种转换。

  • 第一种场景,获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法。
  • 第二种场景,调用无参数的 Thread.join() 方法。其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
  • 第三种场景,调用 LockSupport.park() 方法。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。

3. RUNNABLE 与 TIMED_WAITING 的状态转换

有五种场景会触发这种转换:

  1. 调用带超时参数的 Thread.sleep(long millis) 方法;
  2. 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
  3. 调用带超时参数的 Thread.join(long millis) 方法;
  4. 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
  5. 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。

这里你会发现 TIMED_WAITING 和 WAITING 状态的区别,仅仅是触发条件多了超时参数

4. 从 NEW 到 RUNNABLE 状态

Java 刚创建出来的 Thread 对象就是 NEW 状态,而创建 Thread 对象主要有两种方法。一种是继承 Thread 对象,重写 run() 方法。

5. 从 RUNNABLE 到 TERMINATED 状态

线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断 run() 方法的执行,Java 的 Thread 类里面有个 stop() 方法,不过已经标记为 @Deprecated,所以不建议使用了。正确的姿势其实是调用 interrupt() 方法。

那 stop() 和 interrupt() 方法的主要区别是什么呢?

stop() 方法会真的杀死线程,不给线程喘息的机会,如果线程持有 ReentrantLock 锁,被 stop() 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁,那其他线程就再也没机会获得 ReentrantLock 锁,这实在是太危险了。所以该方法就不建议使用了,类似的方法还有 suspend() 和 resume() 方法,这两个方法同样也都不建议使用了,所以这里也就不多介绍了。

而 interrupt() 方法就温柔多了,interrupt() 方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。被 interrupt 的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。

总结

理解 Java 线程的各种状态以及生命周期对于诊断多线程 Bug 非常有帮助,多线程程序很难调试,出了 Bug 基本上都是靠日志,靠线程 dump 来跟踪问题,分析线程 dump 的一个基本功就是分析线程状态,大部分的死锁、饥饿、活锁问题都需要跟踪分析线程的状态。

你可以通过 jstack 命令或者Java VisualVM这个可视化工具将 JVM 所有的线程栈信息导出来,完整的线程栈信息不仅包括线程的当前状态、调用栈,还包括了锁的信息。

课后思考

下面代码的本意是当前线程被中断之后,退出while(true),你觉得这段代码是否正确呢?

Thread th = Thread.currentThread();
while(true) {
  if(th.isInterrupted()) {
    break;
  }
  // 省略业务代码无数
  try {
    Thread.sleep(100);
  }catch (InterruptedException e){
    e.printStackTrace();
  }
}

答:可能出现无限循环,线程在sleep期间被打断了,抛出一个InterruptedException异常,try catch捕捉此异常,应该重置一下中断标示,因为抛出异常后,中断标示会自动清除掉!

Thread th = Thread.currentThread();
while(true) {
  if(th.isInterrupted()) {
    break;
  }
  // 省略业务代码无数
  try {
    Thread.sleep(100);
  }catch (InterruptedException e){
    Thread.currentThread().interrupt();
    e.printStackTrace();
  }
}
2019/12/05 posted in  极客-Java并发实战

09 四大组件的工作过程

9.1 四大组件的运行状态 / 316

1、Activity(展示型组件)

Activity的主要作用是展示一个界面并和用户交互,它扮演的是一种前台界面的角色。

  • 需要借助Intent启动。有显示Intent和隐式Intent。隐式Intent指向一个或多个目标Activity组件,当然也可能没有任何一个Activity组件可以处理这个隐式Intent。
  • 可以具有特定的启动模式,比如singleTop、singleTask等。
  • 通过Activity的finish方法来结束一个Activity组件的运行。

2、Service(计算型组件)

Service用于在后台执行一系列计算任务。

  • 启动状态:做后台计算,不需要和外界有直接的交互。
  • 绑定状态:这个时候Service内部同样可以进行后台计算,但是处于这种状态时外界可以很方便的和Service组件进行通信。
  • 尽管Service组件用于执行后台计算,但它本身是运行在主线程中的,因此耗时的后台计算仍然需要在单独的线程中去完成。
  • 灵活采用stopService和unBindService这两个方法才能完全停止一个Service组件。

3、BroadcastReceiver(消息型组件)

BroadcastReceiver用于在不同的组件乃至不同的应用之间传递消息。

  • 可以在AndroidManifest中静态注册
  • 动态注册,Context.registerReceiver()和 Context.unRegisterReceiver()
  • 通过Context的一系列send方法来发送广播,发送和接收过程的匹配是通过广播接收者的来描述的。
  • 不适合用来执行耗时操作。
  • BroadcastReceiver组件一般来说不需要停止,它也没有停止的概念。

4、ContentProvider(数据共享型组件)

ContentProvider用于向其他组件乃至其他应用共享数据。

  • 它的内部需要实现增删查改这四种操作,在它的内部维持着一份数据集合,这个数据集合既可以通过数据库来实现,也可以采用其他任何类型来实现,比如List和Map,ContentProvider对数据集合的具体实现并没有任何要求。
  • ContentProvider内部的insert、deleted、update和query方法需要处理好线程同步,因为这几个方法是在Binder线程池中被调用的。
  • ContentProvider无需手动停止。

9.2 Activity的工作过程 / 318

performLaunchActivity主要完成:

  1. 从ActivityClientRecord中获取待启动的Activity的组件信息
  2. 通过Instrumentation的newActivity方法使用类加载器创建Activity对象
  3. 通过LoadedApk的makeApplication方法来尝试创建新的Application对象
  4. 创建ContextImpl对象并通过Activity的attach方法来完成一些重要数据的初始化
  5. 调用Activity的onCreate()方法

9.3 Service的工作过程 / 336

9.3.1 Service的启动过程 / 336

handleCreateService主要完成

  1. 通过类加载器创建Service的实例
  2. 创建Application对象并调用onCreate()
  3. 创建ContextImpl并通过Service的attach方法建立联系
  4. 调用Service的onCreate方法并将Service对象存储到ActivityThread的一个列表中

9.3.2 Service的绑定过程 / 344

9.4 BroadcastReceiver的工作过程 / 352

9.4.1 广播的注册过程 / 353

9.4.2 广播的发送和接收过程 / 356

9.5 ContentProvider的工作过程 / 362

2019/03/06 posted in  Android开发艺术探索

09 注入与依赖注入框架

9.1 注解

从JDK 5开始,Java增加了注解,注解是代码里的特殊标记,这些标记可以在编译、类加载、运行时被读取,并执行相应的处理。通过 使用注解,开发人员可以在不改变原有逻辑的情况下,在源文件中嵌入一些补充的信息。代码分析工具、 开发工具和部署工具可以通过这些补充信息进行验证、处理或者进行部署。

9.1.1 注解分类

注解分为标准注解和元注解。

1.标准注解

标准注解有以下4种:

  • @Override:
    • 对覆盖超类中的方法进行标记,如果被标记的方法并没有实际覆盖超类中的方法,则编 译器会发出错误警告。
  • @Deprecated:
    • 对不鼓励使用或者已过时的方法添加注解,当编程人员使用这些方法时,将会在编译 时显示提示信息。
  • @SuppressWarnings:
    • 选择性地取消特定代码段中的警告。
  • @SafeVarargs:J
    • DK 7新增,用来声明使用了可变长度参数的方法,其在与泛型类一起使用时不会出现类型安全问题。

2.元注解

元注解,它用来注解其他注解,从而创建新的注解。元注解有以下几种。

  • @Targe:
    • 注解所修饰的对象范围。
  • @Inherited:
    • 表示注解可以被继承。
  • @Documented:
    • 表示这个注解应该被JavaDoc工具记录。 * @Retention:用来声明注解的保留策略。
  • @Repeatable:
    • JDK 8 新增,允许一个注解在同一声明类型(类、属性或方法)上多次使用。

其中@Targe注解取值是一个ElementType类型的数组,其中有以下几种取值,对应不同的对象范围。

  • ElementType.TYPE:能修饰类、接口或枚举类型。
  • ElementType.FIELD:能修饰成员变量。
  • ElementType.METHOD:能修饰方法。
  • ElementType.PARAMETER:能修饰参数。
  • ElementType.CONSTRUCTOR:能修饰构造方法。
  • ElementType.LOCAL_VARIABLE:能修饰局部变量。
  • ElementType.ANNOTATION_TYPE:能修饰注解。
  • ElementType.PACKAGE:能修饰包。
  • ElementType.TYPE_PARAMETER:类型参数声明。
  • ElementType.TYPE_USE:使用类型。

其中@Retention注解有3种类型,分别表示不同级别的保留策略。

  • RetentionPolicy.SOURCE:源码级注解。
    • 注解信息只会保留在.java源码中,源码在编译后,注解信息被丢弃,不会保留在.class中。
  • RetentionPolicy.CLASS:编译时注解。
    • 注解信息会保留在.java 源码以及.class 中。当运行Java程序时, JVM会丢弃该注解信息,不会保留在JVM中。
  • RetentionPolicy.RUNTIME:运行时注解。
    • 当运行Java程序时,JVM也会保留该注解信息,可以通过反射获取该注解信息。

9.1.2 定义注解

1)基本定义

定义新的注解类型使用@interface关键字

//定义
public @interface Swordsman{}

//使用
@Swordsman
public class AnnotationTest{}

2)定义成员变量

注解只有成员变量,没有方法。注解的成员变量在注解定义中以“无形参的方法”形式来声明,其“方法名”定义了该成员变量的名字,其返回值定义了该成员变量的类型。还可以使用default关键字为其指定默认值。

//定义注解和成员变量
public @interface Swordsman{
    String name();
    int age();
}
//使用
public class AnnotationTest{
    @Swordsman(name="张无忌",age=23)
    public void fighting(){ }
}
//default指定默认值
public @interface Swordsman{
    String name() default "张无忌";
    int age() default 23;
}

3)定义运行时/编译时注解

可以用@Retention来设定注解的保留策略,这 3个策略的生命周期长度为 SOURCE <CLASS< RUNTIME。生命周期短的能起作用的地方,生命周期长的一定也能起作用。

  • 一般如果需要在运行时去动态获取注解信息,那只能用RetentionPolicy.RUNTIME;
  • 如果要在编译时进行一些预处理操作,比如生成一些辅助代码,就用 RetentionPolicy.CLASS;
  • 如果只是做一些检查性的操作,比如@Override 和 @SuppressWarnings,则可选用RetentionPolicy.SOURCE。当设定为RetentionPolicy.RUNTIME时,这个注解就是运行时注解。

9.1.3 注解处理器

对于不同的注解有不同的注解处理器。 虽然注解处理器的编写会千变万化,但是其也有处理标准,比如:针对运行时注解会采用反射机制处理, 针对编译时注解会采用 AbstractProcessor 来处理。

1.运行时注解处理器

处理运行时注解需要用到反射机制。通过反射获得Field、Method等信息,调用getAnnotation()方法即可获得注解信息。

2.编译时注解处理器

  1. 定义注解
  2. 编写注解处理器
    1. 注解处理器ClassProcessor,它继承AbstractProcessor
    2. 方法有init、process、getSupportedAnnotationTypes、getSupportedSourceVersion等。
  3. 注册注解处理器
    1. 使用Google 开源的AutoService
    2. 在ClassProcessor中添加@AutoService(Processor.class)
  4. 应用注解
  5. 使用android-apt插件
    1. 仅仅在编译时期去依赖注解处理器所在的函数库并进行工作,但不会打包到APK中。
    2. 为注解处理器生成的代码设置好路径,以便Android Studio能够找到它。

9.2 依赖注入的原理

9.2.1 控制反转与依赖注入

1.控制反转

IoC是Inversion of Control的缩写,即控制反转,借助于“第三方”实现具有依赖关系的对象之间的解耦。

  1. 引入IoC容器之前:对象A依赖于对象B,那么对象A在初始化的时候,自己必须主动去创建对象B或者使用已经创建的对象B。无论是创建对象B还是使用对象B,控制权都在自己手上。
  2. 引入IoC容器之后:由于IoC容器的加入,对象A与对象B之间失去了直接联系,所以,当对象A运行到需要对象B的时候,IoC容器会主动创建一个对象B注入到对象A需要的地方。
  3. 通过引入Ioc容器前后的对比,可以看出:对象A获得依赖对象B的过程,由主动行为变为被动行为,控制权颠倒过来了,这就是控制反转这个名称的由来。

2.依赖注入

Martin Fowler提问:
控制反转是“哪些方面的控制被反转了呢?”
答:“获得依赖对象的过程被反转了”。

控制被反转之后,获得依赖对象的过程由自身管理变为由IoC容器主动注入。于是,他给控制反转取了一个更合适的名字,叫作依赖注入(Dependency Injection),简称DI。所谓依赖注入,是指由IoC容器在运行期间,动态地将某种依赖关系注入到对象中。

9.2.2 依赖注入的实现方式

这里举一个汽车的例子,汽车类Car包含了引擎Engine等组件:

public class Car{
    private Engine mEngine;
    public Car(){
        mEngine = new PetrolEngine();
    }
}
  1. 构造方法注入

    public class Car{
        private Engine mEngine;
    public Car(Engine mEngine){
    this.mEngine = mEngine;
    }
    }
  2. Setter方法注入

    public class Car{
        private Engine mEngine;
    public void set(Engine mEngine){
    this.mEngine = mEngine;
    }
    }
  3. 接口注入

    public interface ICar{
        public void setEngine(Engine engine);
    }
    public class Car implements ICar{
    private Engine mEngine;
    @Override
    public void setEngine(Engine engine){
    this.mEngine = engine;
    }
    }

9.3 依赖注入框架

9.3.1 为何使用依赖注入框架

9.3.2 解析ButterKnife

1.ButterKnife的注解使用方法

  1. 添加依赖库
  2. 绑定控件
    • @BindView
  3. 绑定资源
    • @BindString、@BindArray、@BindBool、@BindColor、@BindDimen、@BindDrawable和@BindBitmap
  4. 绑定监听
    • @OnClick、@OnLongClick、@OnTextChanged、@OnTouch、@OnItemClick
  5. 可选绑定
    • @Nullable防止找不到资源

2. ButterKnife原理解析

  1. ButterKnifeProcessor源码分析
    1. ButterKnifeProcessor#process#findAndParseTargets:查找所有注解并解析
    2. brewJava:方法将使用注解的类生成一个JavaFile
  2. ButterKnife的bind方法
    1. 得到Activity的 DecorView,findBindingConstructorForClass方法获得构造器并生成实例
  3. 生成的辅助类分析
    1. DecorView传入这个类中,通过findViewById将View返回。

9.3.3 解析Dagger2

Dagger2是一个基于JSR-330(Java依赖注入)标准的依赖注入框架,在编译期间自动生成代码,负责依赖对象的创建。

1.注解使用方法

  1. 添加依赖库
  2. @Inject和@Component

    1. @Inject:用于标记需要注入的依赖
    2. @Component:注入器,可以编译生成类,调用注入方法
    @Component
    public interface MainActivityComponent {
    void inject (MainActivity activity) ;
    }
    public class MainActivity extends AppCompatActivity {
    @Inject
    Watch watch;
    @Override
    protected void onCreate (Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    Dagger MainActivityComponent.create().inject(this);
    watch.work();
    }
    }
  3. @Module和@Provides

    1. 如果项目中使用了第三方的类库,或需要注入的类是抽象类,可以采用@Module和@Provides提供注入器。
    @Module
    public class GsonModule {
    @Provides
    public Gson provideGson() {
    return new Gson();
    }
    }
    @Component (modules = GsonModule.class)
    public interface MainActivityComponent {
    void inject (MainActivity activity);
    }
  4. @Named和@Qualifier

    1. @Qualifier 是限定符,@Named 则是@Qualifier 的一种实现。
    2. 当有两个相同的依赖时,它们都继承同一个父类或者均实现同一个接口。当它们被提供给高层时,Component 就不知道我们到底要提供哪一个依赖 对象了,因为它找到了两个。
    3. 或者通过自定义注解来实现。
    @Module
    public class Eng ineModule {
    @Provides
    @Named("Gasoline")
    public Engine provideGasoline() {
    return new GasolineEngine() ;
    }
    @Provides
    @Named("Diesel")
    public Engine provideDiesel() {
    return new DieselEngine() ;
    }
    }
    public class Car {
    private Engine engine;
    @Inject
    public Car(@Named("Diesel") Engine engine) {
    this.engine = engine;
    }
    }
    @Qualifier
    @Retention(RUNTIME)
    public dinterface Gasoline {}
    @Qualifier
    @Retention(RUNTIME)
    public dinterface Diesel {}
    @Module
    public class EngineModule {
    @Provides
    @Gasoline
    public Engine provideGasoline() {
    return new Gasol ineEngine();
    }
    @Provides
    @Diesel
    public Engine provideDiesel() {
    return new DieselEngine();
    }
    }
    public class Car {
    private Engine engine;
    @Inject
    public Car(@Gasoline Engine engine) {
    this.engine = engine;
    }
    public String run() {
    return engine.work();
    }
    }
  5. @Singleton和@Scope

    1. @Scope是用来自定义注解的,而@Singleton则是用来配合实现局部单例和全局单例的。@Singleton本身不具备创建单例的能力。
    2. 如果想实现全局单例,我们可以用@Scope结合Application来实现。
  6. @Component的dependencies

    1. @Component也可以用dependencies依赖于其他Component。

2.懒加载
Dagger2提供了懒加载模式,在@Inject的时候不初始化,而是使用的时候,调用get方法来获取实例。
3.Dagger2原理解析

  1. WatchModule_ProvideWatchFactory 用来生成 Watch 实例;
  2. Dagger2Activity_MemberInject 将 Watch 实例赋值 给 MainActivity 的成员变量 Watch;
  3. DaggerActivityComponent则作为程序入口和桥梁,负责初始化 WatchModule_ProvideWatchFactory和Dagger2Activity_MemberInject,并将它们串联起来。

9.4 本章小结

2019/03/27 posted in  Android进阶之光

09 类加载及执行子系统的案例与实战

2019/11/28 posted in  深入理解虚拟机

09 责任链模式

请求的链式处理——职责链模式

顾名思义,责任链模式(Chain of Responsibility Pattern)为请求创建了一个接收者对象的链。这种模式给予请求的类型,对请求的发送者和接收者进行解耦。这种类型的设计模式属于行为型模式。
在这种模式中,通常每个接收者都包含对另一个接收者的引用。如果一个对象不能处理该请求,那么它会把相同的请求传给下一个接收者,依此类推。

介绍

意图:避免请求发送者与接收者耦合在一起,让多个对象都有可能接收请求,将这些对象连接成一条链,并且沿着这条链传递请求,直到有对象处理它为止。
主要解决:职责链上的处理者负责处理请求,客户只需要将请求发送到职责链上即可,无须关心请求的处理细节和请求的传递,所以职责链将请求的发送者和请求的处理者解耦了。
何时使用:在处理消息的时候以过滤很多道。
如何解决:拦截的类都实现统一接口。
关键代码:Handler 里面聚合它自己,在 HandlerRequest 里判断是否合适,如果没达到条件则向下传递,向谁传递之前 set 进去。
应用实例: 1、红楼梦中的"击鼓传花"。 2、JS 中的事件冒泡。 3、JAVA WEB 中 Apache Tomcat 对 Encoding 的处理,Struts2 的拦截器,jsp servlet 的 Filter。
优点: 1、降低耦合度。它将请求的发送者和接收者解耦。 2、简化了对象。使得对象不需要知道链的结构。 3、增强给对象指派职责的灵活性。通过改变链内的成员或者调动它们的次序,允许动态地新增或者删除责任。 4、增加新的请求处理类很方便。
缺点: 1、不能保证请求一定被接收。 2、系统性能将受到一定影响,而且在进行代码调试时不太方便,可能会造成循环调用。 3、可能不容易观察运行时的特征,有碍于除错。
使用场景: 1、有多个对象可以处理同一个请求,具体哪个对象处理该请求由运行时刻自动确定。 2、在不明确指定接收者的情况下,向多个对象中的一个提交一个请求。 3、可动态指定一组对象处理请求。
注意事项:在 JAVA WEB 中遇到很多应用。

2019/11/01 posted in  菜鸟-设计模式

09 队列:队列在线程池等优先资源池中的应用

CPU资源是有限的,任务的处理速度与线程个数并不是线性正相关。相反,过多的线程反而会导致CPU频繁切换,处理性能下降。所以,线程池的大小一般都是综合考虑要处理任务的特点和硬件环境,来事先设置的。

当我们向固定大小的线程池中请求一个线程时,如果线程池中没有空闲资源了,这个时候线程池如何处理这个请求?是拒绝请求还是排队请求?各种处理策略又是怎么实现的呢?

如何理解“队列”?

你可以把它想象成排队买票,先来的先买,后来的人只能站末尾,不允许插队。先进者先出,这就是典型的“队列”。

栈只支持两个基本操作:入栈push()和出栈pop()。

队列跟栈非常相似,支持的操作也很有限,最基本的操作也是两个:

  • 入队enqueue(),放一个数据到队列尾部;
  • 出队dequeue(),从队列头部取一个元素。

队列跟栈一样,也是一种操作受限的线性表数据结构

队列的应用也非常广泛,特别是一些具有某些额外特性的队列,比如循环队列、阻塞队列、并发队列。比如高性能队列Disruptor、Linux环形缓存,都用到了循环并发队列;Javaconcurrent并发包利用ArrayBlockingQueue来实现公平锁等。

顺序队列和链式队列

跟栈一样,队列可以用数组来实现,也可以用链表来实现。用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列

对于栈来说,我们只需要一个栈顶指针就可以了。但是队列需要两个指针:一个是head指针,指向队头;一个是tail指针,指向队尾。

a b c d 入队:

a b 出队:

随着不停地进行入队、出队操作,head和tail都会持续往后移动。当tail移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了。这个问题该如何解决呢?

答:数据搬移

基于链表的队列实现方法

循环队列

循环队列的代码实现难度要比前面讲的非循环队列难多了,最关键的是,确定好队空和队满的判定条件

  • 队空时:head==tail;
  • 队满时:(tail+1)%n=head。

当队列满时,图中的tail指向的位置实际上是没有存储数据的。所以,循环队列会浪费一个数组的存储空间。

阻塞队列和并发队列

阻塞队列其实就是在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。

这种基于阻塞队列实现的“生产者-消费者模型”,可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”。

而且不仅如此,基于阻塞队列,我们还可以通过协调“生产者”和“消费者”的个数,来提高数据的处理效率。比如前面的例子,我们可以多配置几个“消费者”,来应对一个“生产者”。

在多线程情况下,会有多个线程同时操作队列,这个时候就会存在线程安全问题,那如何实现一个线程安全的队列呢?

线程安全的队列我们叫作并发队列。最简单直接的实现方式是直接在enqueue()、dequeue()方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。实际上,基于数组的循环队列,利用CAS原子操作,可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因。在实战篇讲Disruptor的时候,我会再详细讲并发队列的应用。

解答开篇

线程池没有空闲线程时,新的任务请求线程资源时,线程池该如何处理?各种处理策略又是如何实现的呢?

我们一般有两种处理策略。第一种是非阻塞的处理方式,直接拒绝任务请求;另一种是阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。

那如何存储排队的请求呢?

我们希望公平地处理每个排队的请求,先进者先服务,所以队列这种数据结构很适合来存储排队请求。

我们前面说过,队列有基于链表和基于数组这两种实现方式。这两种实现方式对于排队请求又有什么区别呢?

基于链表的实现方式,可以实现一个支持无限排队的无界队列(unboundedqueue),但是可能会导致过多的请求排队等待,请求处理的响应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的。

而基于数组实现的有界队列(boundedqueue),队列的大小有限,所以线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝,这种方式对响应时间敏感的系统来说,就相对更加合理。不过,设置一个合理的队列大小,也是非常有讲究的。队列太大导致等待的请求太多,队列太小会导致无法充分利用系统资源、发挥最大性能。

除了前面讲到队列应用在线程池请求排队的场景之外,队列可以应用在任何有限资源池中,用于排队请求,比如数据库连接池等。实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队。

2019/04/01 posted in  极客-数据结构与算法之美

09集合

2019/03/28 posted in  Java核心技术

10 Android的消息机制

10.1 Android的消息机制概述

Android的消息机制主要是指Handler的运行机制,从开发的角度来说,Handler 是Android消息机制的上层接口,通过它可以轻松地将一个任务切换到Handler所在的线程中去执行。Handler 的运行需要底层的MessageQueue和Looper的支撑。

  • MessageQueue 消息队列,内部存储了一组消息,以队列的形式对外提供插入和删除的工作,采用单链表的数据结构来存储消息列表。
  • Looper 消息循环。由于MessageQueue只是一个消息的存储单元,它不能去处理消息,而Looper就填补了这个功能,Looper 会以无限循环的形式去查找是否有新消息,如果有的话就处理消息,否则就一直等待着 。
  • ThreadLocal 并不是线程,它的作用是可以在每个线程中存储数据。我们知道,Handler 创建的时候会采用当前线程的Looper来构造消息循环系统,那么Handler内部如何获取到当前线程的Looper呢?这就要使用ThreadLocal了,ThreadLocal可以在不同的线程中互不干扰地存储并提供数据,通过ThreadLocal可以轻松获取每个线程的Looper。
  • 当然需要注意的是,线程是默认没有Looper的,如果需要使用Handler就必须为线程创建Looper。我们经常提到的主线程,也叫UI线程,它就是ActivityThread, ActivityThread 被创建时就会初始化Looper,这也是在主线程中默认可以使用Handler的原因。

10.2 Android的消息机制分析

子线程中无法访问UI

  • 如何解决子线程中无法访问UI
    • 答:使用Handler,将访问UI的工作切换到主线程。
  • 为什么子线程中无法访问UI
    • 答:这是因为Android的UI控件不是线程安全的,如果在多线程中并发访问可能会导致UI控件处于不可预期的状态。那为什么系统不对UI控件的访问加上锁机制呢?缺点有两个:首先加上锁机制会让UI访问的逻辑变得复杂;其次锁机制会降低UI访问的效率,因为锁机制会阻塞某些线程的执行。鉴于这两个缺点,最简单且高效的方法就是采用单线程模型来处理UI操作,对于开发者来说也不是很麻烦,只是需要通过Handler切换一下UI访问的执行线程即可。

Handler工作原理

  1. 创建Handler和Looper。
    1. Handler创建时会采用当前线程的Looper来构建内部的消息循环系统,如果当前线程没有Looper,那么就会报错,需要手动创建Looper。
  2. 发送消息。
    1. 通过Handler的post方法将一个Runnable投递到Handler内部的Looper中去处理,
    2. 也可以通过Handler的send方法发送一个消息,这个消息同样会在Looper中去处理。其实post方法最终也是通过send方法来完成的,接下来主要来看一下send方法的工作过程。
    3. 当Handler的send方法被调用时,它会调用MessageQueue的enqueueMesssagge方法将这个消息放入消息队列中。
  3. 处理消息。
    1. Looper发现有新消息到来时,就会处理这个消息,最终消息中的Runnable或者Handler的handleMessage方法就会被调用。
    2. 注意Looper是运行在创建Handler所在的线程中的,这样一来Handler中的业务逻辑就被切换到创建Handler所在的线程中去执行了。

10.2.1 ThreadLocal的工作原理

ThreadLocal是一个线程内部的数据存储类,通过它可以在指定的线程中存储数据,数据存储以后,只有在指定线程中可以获取到存储的数据,对于其他线程来说则无法获取到数据。

在日常开发中用到ThreadLocal的地方较少,但是在某些特殊的场景下,通过ThreadLocal可以轻松地实现一些看起来很复杂的功能,这一点在Android的源码中也有所体现,比如Looper、 ActivityThread以及AMS中都用到了ThreadLocal。

ThreadLocal的使用场景:

  1. 当某些数据是以线程为作用域并且不同线程具有不同的数据副本的时候,就可以考虑采用ThreadLocal。比如对于Handler来说,它需要获取当前线程的Looper, 很显然Looper的作用域就是线程并且不同线程具有不同的Looper,这个时候通过ThreadLocal就可以轻松实现Looper在线程中的存取。
  2. ThreadLocal另一个使用场景是复杂逻辑下的对象传递,比如监听器的传递,有些时候一个线程中的任务过于复杂,这可能表现为函数调用栈比较深以及代码入口的多样性,在这种情况下,我们又需要监听器能够贯穿整个线程的执行过程,这个时候可以怎么做呢?其实这时就可以采用ThreadLocal,采用ThreadLocal可以让监听器作为线程内的全局对象而存在,在线程内部只要通过get方法就可以获取到监听器。

10.2.2 消息队列的工作原理

消息队列在Android中指的是MessageQueue, MessageQueue主要包含两个操作:插入和读取。数据结构是单链表,单链表在插入和删除上比较有优势。

  • enqueueMessage 插入消息
  • next 读取并移除一条消息。

next方法是一个无限循环的方法,如果消息队列中没有消息,那么next方法会一直阻塞在这里。 当有新消息到来时,next 方法会返回这条消息并将其从单链表中移除。

10.2.3 Looper的工作原理

Looper在Android的消息机制中扮演着消息循环的角色,具体来说就是它会不停地从MessageQueue中查看是否有新消息,如果有新消息就会立刻处理,否则就一直阻塞在那里。

Looper的工作原理

  1. Looper.prepare()创建Looper
    • Looper的构造方法中会创建一个MessageQueue
  2. Looper.loop()开启消息轮询
  3. msg.target.dispatchMessage(msg)消息的发送者Handler分发消息然后处理

Looper的其他方法:

  • prepareMainLooper方法,这个方法主要是给主线程也就是ActivityThread创建Looper使用的,其本质也是通过prepare方法来实现的。由于主线程的Looper比较特殊,所以Looper提供了一个getMainLooper方法,通过它可以在任何地方获取到主线程的Looper。
  • 退出Looper,Looper提供了quit和quitSafely来退出一个Looper,二者的区别是: quit 会直接退出Looper,而quitSafely 只是设定一个退出标记,然后把消息队列中的已有消息处理完毕后才安全地退出。Looper 退出后,通过Handler发送的消息会失败,这个时候Handler的send方法会返回false。在子线程中,如果手动为其创建了Looper,那么在所有的事情完成以后应该调用quit方法来终止消息循环,否则这个子线程就会一直处于等待的状态,而如果退出Looper以后,这个线程就会立刻终

10.2.4 Handler的工作原理

Handler的工作主要包含消息的发送和接收过程。消息的发送可以通过post 的一系列方法以及send的一 系列方法来实现, post的一系列方法最终 是通过send的一系列方法来实现的。发送一条消息的典型过程如下所示。

  1. 发送消息
    • Handler.sendMessageDelayed -> Handler.sendMessageAtTime() -> MessageQueue.enqueueMessage()
  2. 分发消息 (Handler#dispatchMessage)
    1. 使用Message的callback处理消息
    2. 创建Handler的实例handleCallback处理消息
    3. 调用Handler的handleMessage来处理消息

10.3 主线程的消息循环

主线程ActivityThread的main方法中:

  1. 创建主线程的Looper:Looper.prepareMainLooper();
  2. 开启主线程的消息循环:Looper.loop();

ActivityThread.H
负责ActivityThread中的消息队列进行,它内部定义了一组消息类型,主要包含了四大组件的启动和停止等过程,如下所示。

Activity和四大组件通信间通信:

  1. ActivityThread通过ApplicationThread和AMS发送请求
  2. AMS完成请求后回调ApplicationThread中的Binder方法,然后ApplicationThread会向H发送消息
  3. H收到消息后将ApplicationThread的逻辑切换到Activity中去执行
2019/03/06 posted in  Android开发艺术探索

10 Java虚拟机

10.1 概述

10.1.1 Java虚拟机家族

  1. HotSpot VM
  2. J9 VM
  3. Zing VM

10.1.2 Java虚拟机执行流程


10.2 Java虚拟机结构

10.2.1 Class文件格式

10.2.2 类的生命周期

类的生命周期:
一个Java文件,从被加载到Java虚拟机内存中从内存中卸载的过程。

  1. 加载:查找并加载Class文件。
  2. 链接
    1. 验证:确保被导入类型的正确性。
    2. 准备:为类的静态字段分配字段,并用默认值初始化这些字段。
    3. 解析:虚拟机将常量池内的符号引用替换为直接引用。
  3. 初始化:将类变量初始化为正确的初始值。
  4. 使用
  5. 卸载

10.2.3 类加载子系统

类加载子系统通过多种类加载器来查找和加载Class文件到Java虚拟机中。Java虚拟机有两种你那个类加载器:系统加载器和自定义加载器。

系统加载器:

  1. Bootstrap ClassLoader(引导类加载器)
  2. Extensions ClassLoader(拓展类加载器)
  3. Application ClassLoader(应用程序类加载器)

10.2.4 运行时数据区域

  1. 程序计数器
  2. Java虚拟机栈
  3. 本地方法栈
  4. Java堆
  5. 方法区
  6. 运行时常量池
  7. 直接内存

10.3 对象的创建

  1. 判断对象对应的类是否加载、链接和初始化
  2. 为对象分配内存
    1. 指针碰撞
    2. 空闲列表
  3. 处理并发安全问题
    1. 分配内存进行同步处理
    2. 本地线程分配缓冲
  4. 初始化分配到的内存空间
  5. 设置对象的对象头
  6. 执行init方法进行初始化

10.4 对象的堆内存布局

以HotSpot虚拟机为例,对象在堆内存的布局分为三个区域,分别是对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

10.5 oop-klass模型

oop-klass模型模型是用来描述Java对象实例的一种模型,它分为两个部分,OOP(Ordinary Object Pointer)指普通对象指针,用来表示对象的实例信息。klass用来描述元数据。

10.6 垃圾标记算法

10.6.1 Java中的引用

  1. 强引用:新建对象时引用,不会回收。
  2. 软引用:SoftReference,内存不足时回收。
  3. 弱引用:WeakReference,GC时回收。
  4. 虚引用:PhantomReference,和对象生命周期没有关系;被回收时会收到一个系统通知。

10.6.2 引用计数算法

基本思想:背个对象都有一个引用计数器,当对象在某处类引用的时候,它的引用计数器加1,引用失效时减1。当引用计数器中的值为0,则该对象就不能被使用,变成了垃圾。

目前主流Java虚拟机并没有选择使用引用计数算法,因为它没有解决对象之间互相循环引用的问题。

10.6.3 根搜索算法

思想:选定一些对象作为GC Roots,并组成根对象集合,然后以这些GC Roots的对象作为起始点,向下搜索,如果目标对象到GC Roots是连接着的,我们则称为该对象是可达的,如果不可达则说说明目标对象是可以被回收的对象。如图:

在Java中,可以作为GC Roots的对象主要有以下几种:

  • Java栈中引用的对象。
  • 本地方法栈中JNI引用的对象。
  • 方法区中运行时常量池引用的对象。
  • 方法区中静态属性引用的对象。
  • 运行中的线程。
  • 由引导类加载器加载的对象。
  • GC控制的对象。

10.7 Java对象在虚拟机中的生命周期

  1. 创建阶段(Created)
    1. 为对象分配存储空间
    2. 构造对象
    3. 从超类到子类对static成员进行初始化
    4. 递归调用超类的构造方法
    5. 调用子类的构造方法
  2. 应用阶段(In Use)
  3. 不可见阶段(Invisible)
  4. 不可达阶段(Unreachable)
  5. 收集阶段(Collected)
  6. 终结阶段(Finalized)
  7. 对象空间重新分配阶段(Deallocated)

10.8 垃圾收集算法

10.8.1 标记—清除算法

  • 标记阶段:标记处可以回收的对象
  • 清除阶段:回收被标记的对象所占的空间。

缺点:

  • 标记和清除的效率不高。
  • 容易产生大量不连续的内存碎片,碎片太多可能导致没有足够的连续内存分配给较大对象,从而触发新的一次垃圾收集动作。

10.8.2 复制算法

为了解决标记—清除算法效率不高的问题。

  1. 它把内存空间划分为两个相等的区域,每次只使用其中一个区域;
  2. 在垃圾收集时,遍历当前使用的区域,把存活的对象复制到另一个区域中,最后将当前使用的区域的可回收对象进行回收。

  • 这种算法每次对整个半区进行内存回收,不需要考虑内存碎片问题,代价就是使用内存为原来的一般。
  • 复制算法的效率与存活对象数目有很大关系,如果存活对象很少,复制算法的效率就会很高。所以复制算法广泛应用于新生代中。

10.8.3 标记-压缩算法

老年代不适用复制算法,因为老年代对象存活率高,会有很多复制操作,导致效率变低。

标记-压缩算法在标记可回收的对象后,将所有存活的对象压缩到内存的另一端,使他们紧凑地排列在一起,然后对边界以外的内存进行回收。

10.8.4 分代收集算法

对不同生命周期的对象采取不同的收集算法,这就是分代的概念。

  • 新生代
    • Eden空间
    • From Survivor空间
    • To Survivor空间
  • 老年代

Eden空间中大多数对象生命周期很短,Eden空间和两个Survivor空间所占比例为8:1。

根据Java堆区的空间划分,垃圾收集的类型分为两种:

  • Minor Collection:新生代垃圾收集
  • Full Collection :老年代收集。又称Major Collection。

Full Collection通常情况下伴随至少一次的Minor Collection,收集频率较低,耗时较长。

10.9 本章小结

2019/03/27 posted in  Android进阶解密

10 | Java对象的内存布局

2019/12/14 posted in  极客-深入拆解Java虚拟机

10 | Java线程(中):创建多少线程才是合适的?

  • 为什么要使用多线程?
  • 多线程的应用场景
  • 创建多少线程合适?

要解决这个问题,首先要分析以下两个问题:

  1. 为什么要使用多线程?
  2. 多线程的应用场景有哪些?

为什么要使用多线程?

使用多线程,本质上就是提升程序性能。

在提升性能之前,首要问题是:如何度量性能

度量性能的指标有很多,但是有两个指标是最核心的,它们就是延迟和吞吐量

  • 延迟指的是发出请求到收到响应这个过程的时间;延迟越短,意味着程序执行得越快,性能也就越好。
  • 吞吐量指的是在单位时间内能处理请求的数量;吞吐量越大,意味着程序能处理的请求越多,性能也就越好。

这两个指标内部有一定的联系(同等条件下,延迟越短,吞吐量越大),但是由于它们隶属不同的维度(一个是时间维度,一个是空间维度),并不能互相转换。

我们所谓提升性能,从度量的角度,主要是降低延迟,提高吞吐量

多线程的应用场景

要想“降低延迟,提高吞吐量”,对应的方法呢,基本上有两个方向,一个方向是优化算法,另一个方向是将硬件的性能发挥到极致。前者属于算法范畴,后者则是和并发编程息息相关了。那计算机主要有哪些硬件呢?主要是两类:一个是 I/O,一个是 CPU。简言之,在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升 I/O 的利用率和 CPU 的利用率

操作系统解决硬件利用率问题的对象往往是单一的硬件设备,而我们的并发程序,往往需要 CPU 和 I/O 设备相互配合工作,也就是说,我们需要解决 CPU 和 I/O 设备综合利用率的问题。关于这个综合利用率的问题,操作系统虽然没有办法完美解决,但是却给我们提供了方案,那就是:多线程。

如下图所示,如果只有一个线程,执行 CPU 计算的时候,I/O 设备空闲;执行 I/O 操作的时候,CPU 空闲,所以 CPU 的利用率和 I/O 设备的利用率都是 50%。

如果有两个线程,如下图所示,当线程 A 执行 CPU 计算的时候,线程 B 执行 I/O 操作;当线程 A 执行 I/O 操作的时候,线程 B 执行 CPU 计算,这样 CPU 的利用率和 I/O 设备的利用率就都达到了 100%。

我们将 CPU 的利用率和 I/O 设备的利用率都提升到了 100%,会对性能产生了哪些影响呢?通过上面的图示,很容易看出:单位时间处理的请求数量翻了一番,也就是说吞吐量提高了 1 倍。此时可以逆向思维一下,如果 CPU 和 I/O 设备的利用率都很低,那么可以尝试通过增加线程来提高吞吐量

在单核时代,多线程主要就是用来平衡 CPU 和 I/O 设备的。如果程序只有 CPU 计算,而没有 I/O 操作的话,多线程不但不会提升性能,还会使性能变得更差,原因是增加了线程切换的成本。但是在多核时代,这种纯计算型的程序也可以利用多线程来提升性能。为什么呢?因为利用多核可以降低响应时间。

为便于你理解,这里我举个简单的例子说明一下:计算 1+2+… … +100 亿的值,如果在 4 核的 CPU 上利用 4 个线程执行,线程 A 计算 [1,25 亿),线程 B 计算 [25 亿,50 亿),线程 C 计算 [50,75 亿),线程 D 计算 [75 亿,100 亿],之后汇总,那么理论上应该比一个线程计算 [1,100 亿] 快将近 4 倍,响应时间能够降到 25%。一个线程,对于 4 核的 CPU,CPU 的利用率只有 25%,而 4 个线程,则能够将 CPU 的利用率提高到 100%。

创建多少线程合适?

创建多少线程合适,要看多线程具体的应用场景。我们的程序一般都是 CPU 计算和 I/O 操作交叉执行的,由于 I/O 设备的速度相对于 CPU 来说都很慢,所以大部分情况下,I/O 操作执行的时间相对于 CPU 计算来说都非常长,这种场景我们一般都称为 I/O 密集型计算;和 I/O 密集型计算相对的就是 CPU 密集型计算了,CPU 密集型计算大部分场景下都是纯 CPU 计算。I/O 密集型程序和 CPU 密集型程序,计算最佳线程数的方法是不同的。

对于 CPU 密集型计算,多线程本质上是提升多核 CPU 的利用率,所以对于一个 4 核的 CPU,每个核一个线程,理论上创建 4 个线程就可以了,再多创建线程也只是增加线程切换的成本。所以,对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。

对于 I/O 密集型的计算场景,比如前面我们的例子中,如果 CPU 计算和 I/O 操作的耗时是 1:1,那么 2 个线程是最合适的。如果 CPU 计算和 I/O 操作的耗时是 1:2,那多少个线程合适呢?是 3 个线程,如下图所示:CPU 在 A、B、C 三个线程之间切换,对于线程 A,当 CPU 从 B、C 切换回来时,线程 A 正好执行完 I/O 操作。这样 CPU 和 I/O 设备的利用率都达到了 100%。

通过上面这个例子,我们会发现,对于 I/O 密集型计算场景,最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的,我们可以总结出这样一个公式:

最佳线程数 =1 +(I/O 耗时 / CPU 耗时)

我们令 R=I/O 耗时 / CPU 耗时,综合上图,可以这样理解:当线程 A 执行 IO 操作时,另外 R 个线程正好执行完各自的 CPU 计算。这样 CPU 的利用率就达到了 100%。

不过上面这个公式是针对单核 CPU 的,至于多核 CPU,也很简单,只需要等比扩大就可以了,计算公式如下:

最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

总结

很多人都知道线程数不是越多越好,但是设置多少是合适的,却又拿不定主意。其实只要把握住一条原则就可以了,这条原则就是将硬件的性能发挥到极致。上面我们针对 CPU 密集型和 I/O 密集型计算场景都给出了理论上的最佳公式,这些公式背后的目标其实就是将硬件的性能发挥到极致。

对于 I/O 密集型计算场景,I/O 耗时和 CPU 耗时的比值是一个关键参数,不幸的是这个参数是未知的,而且是动态变化的,所以工程上,我们要估算这个参数,然后做各种不同场景下的压测来验证我们的估计。不过工程上,原则还是将硬件的性能发挥到极致,所以压测时,我们需要重点关注 CPU、I/O 设备的利用率和性能指标(响应时间、吞吐量)之间的关系。

课后思考

有些同学对于最佳线程数的设置积累了一些经验值,认为对于 I/O 密集型应用,最佳线程数应该为:2 * CPU 的核数 + 1,你觉得这个经验值合理吗?

不合理,应该根据是IO密集型或者是CPU密集型,具体问题具体分析。
老师:我们曾讲到最佳线程数最终还是靠压测来确定的,实际工作中大家面临的系统,“I/O 耗时 / CPU 耗时”往往都大于 1,所以基本上都是在这个初始值的基础上增加。增加的过程中,应关注线程数是如何影响吞吐量和延迟的。一般来讲,随着线程数的增加,吞吐量会增加,延迟也会缓慢增加;但是当线程数增加到一定程度,吞吐量就会开始下降,延迟会迅速增加。这个时候基本上就是线程能够设置的最大值了。

2019/12/05 posted in  极客-Java并发实战

10 应用架构设计

2019/03/27 posted in  Android进阶之光

10 早起(编译期)优化

10.1概述302

10.2Javac编译器303

10.2.1Javac的源码与调试303

10.2.2解析与填充符号表305

10.2.3注解处理器307

10.2.4语义分析与字节码生成307

10.3Java语法糖的味道311

10.3.1泛型与类型擦除311

10.3.2自动装箱、拆箱与遍历循环315

10.3.3条件编译317

10.4实战:插入式注解处理器318

10.4.1实战目标318

10.4.2代码实现319

10.4.3运行与测试326

10.4.4其他应用案例327

10.5本章 小结328

2019/11/28 posted in  深入理解虚拟机

10 解释器模式

自定义语言的实现——解释器模式

解释器模式(Interpreter Pattern)提供了评估语言的语法或表达式的方式,它属于行为型模式。这种模式实现了一个表达式接口,该接口解释一个特定的上下文。这种模式被用在 SQL 解析、符号处理引擎等。

介绍

意图:给定一个语言,定义它的文法表示,并定义一个解释器,这个解释器使用该标识来解释语言中的句子。
主要解决:对于一些固定文法构建一个解释句子的解释器。
何时使用:如果一种特定类型的问题发生的频率足够高,那么可能就值得将该问题的各个实例表述为一个简单语言中的句子。这样就可以构建一个解释器,该解释器通过解释这些句子来解决该问题。
如何解决:构建语法树,定义终结符与非终结符。
关键代码:构建环境类,包含解释器之外的一些全局信息,一般是 HashMap。
应用实例:编译器、运算表达式计算。
优点: 1、可扩展性比较好,灵活。 2、增加了新的解释表达式的方式。 3、易于实现简单文法。
缺点: 1、可利用场景比较少。 2、对于复杂的文法比较难维护。 3、解释器模式会引起类膨胀。 4、解释器模式采用递归调用方法。
使用场景: 1、可以将一个需要解释执行的语言中的句子表示为一个抽象语法树。 2、一些重复出现的问题可以用一种简单的语言来进行表达。 3、一个简单语法需要解释的场景。
注意事项:可利用场景比较少,JAVA 中如果碰到可以用 expression4J 代替。

2019/11/01 posted in  菜鸟-设计模式

10 递归:如何用三行代码找到“最终推荐人”?

如何理解递归?

递归是一种应用非常广泛的算法(或者编程技巧),数据结构和算法的编码实现都要用到递归,比如DFS深度优先搜索、前中后序二叉树遍历等等。

递归需要满足的三个条件:

  1. 一个问题的解可以分解为几个子问题的解
  2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
  3. 存在递归终止条件

如何编写递归代码?
写出递推公式,找到终止条件
写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。

理解起来比较吃力?
把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。

递归代码要警惕堆栈溢出

为什么递归代码容易造成堆栈溢出呢?
函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。

如何预防堆栈溢出呢?

  1. 我们可以通过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过一定深度(比如1000)之后,我们就不继续往下再递归了,直接返回报错。
  2. 但这种做法并不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算。如果实时计算,代码过于复杂,就会影响代码的可读性。所以,如果最大深度比较小,比如10、50,就可以用这种方法,否则这种方法并不是很实用。

递归代码要警惕重复计算

为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的f(k)。当递归调用到f(k)时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。

其他问题
在时间效率上,递归代码里多了很多函数调用,当这些函数调用的数量较大时,就会积聚成一个可观的时间成本。在空间复杂度上,因为递归调用一次就会在内存栈中保存一次现场数据,所以在分析递归代码空间复杂度时,需要额外考虑这部分的开销。

可以将递归代码改为迭代循环的非递归写法

对于递归代码,你有什么好的调试方法呢?

  1. 打印日志发现,递归值。
  2. 结合条件断点进行调试。
2019/04/01 posted in  极客-数据结构与算法之美

10图形设计程序

2019/03/28 posted in  Java核心技术

11 Android的线程和线程池

  • AsyncTask封装了线程池和Handler,它主要是为了方便开发者在子线程中更新UI。
  • HandlerThread是一种具有消息循环的线程,在它的内部可以使用Handler。
  • IntentService是一个服务,系统对其进行了封装使其可以更方便地执行后台任务,IntentService 内部采用HandlerThread来执行任务,当任务执行完毕后IntentService 会自动退出。 它不容易被系统杀死从而可以尽量保证任务的执行。

11.1 主线程和子线程

  • 主线程:进程所拥有的线程,主要处理界面交互相关的逻辑。
  • 子线程:除主线程之外都是子线程,主要用于执行耗时操作。

11.2 Android中的线程形态

11.2.1 AsyncTask

轻量级的异步任务类,可以执行后台任务以及在子线程中进行UI操作。

主要方法:

  1. onPreExecute()
  2. doInBackground(Params…params):
  3. onProgressUpdate(Progress…values)
  4. onPostExecute(Result result)
  5. onCancelled()
  6. execute(Params...params)

11.2.2 AsyncTask的工作原理

  1. AsyncTask构造方法中:
    1. 实例化Handler,类型是InternalHandler,用于切换到主线程
    2. 实例化mWorker,类型是WorkerRunnable,封装了Params和Result;
    3. 实例化mFuture,类型是FutureTask,参数为mWorker。FutureTask是一个并发类,在这里它充当了Runnable的作用。
  2. 执行任务
    1. execute方法会调用executeOnExecutor(sDefaultExecutor, params)
      1. 参数sDefaultExecutor,类型为SerialExecutor,是一个串行的线程池,一个进程中所有的AsyncTask全部在这个串行的线程池中排队执行。
      2. SerialExecutor#execute。(1) ArrayDeque.offer(Runnable)。把FutureTask对象插入到任务队列mTasks中;(2) scheduleNext()。如果没有正在活动的任务,或者当一个任务执行完后,会调用scheduleNext方法执行下一个任务,直到所有的任务都被执行。scheduleNext中,调用THREAD_POOL_EXECUTOR.execute()真正地执行任务
    2. executeOnExecutor
      1. 调用onPreExecute()
      2. exec.execute(mFuture);线程池执行。
  3. 响应结果
    1. mWorker的call()中调用doInBackground(mParams)postResult()
    2. postResult中,Handler调用sendToTarget()发送消息。
    3. InternalHandler中,onProgressUpdatefinish
  4. 结束或取消onCancelledonPostExecute

    从Android 3.0 开始,默认情况下AsyncTask是串行执行的,可以调用executeOnExecutor方法并行执行。

11.2.3 HandlerThread

  • HandlerThread本质上是一个线程类,它继承了Thread;
  • HandlerThread有自己的内部Looper对象,可以进行looper循环;
  • 通过获取HandlerThread的looper对象传递给Handler对象,可以在handleMessage方法中执行异步任务。
  • 创建HandlerThread后必须先调用HandlerThread.start()方法,Thread会先调用run方法,创建Looper对象。
  • 当不需要HandlerThread时,通过HandlerThread.quit()/quitSafely()方法来终止线程的执行

11.2.4 IntentService

可自动创建子线程来执行任务,且任务执行完毕后自动退出。

  • 在IntentService.onCreate()里创建一个Handle对象即HandlerThread,利用其内部的Looper会实例化一个ServiceHandler对象;
  • 任务请求的Intent会被封装到Message并通过ServiceHandler发送给Looper的MessageQueue,最终在HandlerThread中执行;
  • 在ServiceHandler.handleMessage()中会调用IntentService.onHandleIntent(),可在该方法中处理后台任务的逻辑。

11.3 Android中的线程池

线程池的优点:

  • 重用线程池中的线程,避免线程的创建和销毁带来的性能消耗;
  • 有效控制线程池的最大并发数,避免大量的线程之间因互相抢占系统资源而导致阻塞现象;
  • 能够进行线程管理,提供定时/循环间隔执行等功能。

Android中的线程池的概念来源于Java中的Executor, Executor是一个接口,真正的线程池的实现为ThreadPoolExecutor。ThreadPoolExecutor 提供了一系列参数来配置线程池,通过不同的参数可以创建不同的线程池。

11.3.1 ThreadPoolExecutor

ThreadPoolExecutor构造方法:

public ThreadPoolExecutor(
    int corePoolSize,   //核心线程数
    int maximumPoolSize,//最大线程数
    long keepAliveTime,//非核心线程超时时间
    TimeUnit unit,//keepAliveTime参数的时间单位
    BlockingQueue<Runnable> workQueue,//任务队列
    ThreadFactory threadFactory,//线程工厂,可创建新线程
    RejectedExecutionHandler handler//饱和策略
)

ThreadPoolExecutor的默认工作策略:

  • 如果线程池中的线程数量未达到核心线程数,则会直接启动一个核心线程执行任务。
  • 若线程池中的线程数量已达到或者超过核心线程数量,则任务会被插入到任务列表等待执行。
  • 若任务无法插入到任务列表中,往往由于任务列表已满,此时如果:
    • 线程数量未达到线程池最大线程数,则会启动一个非核心线程执行任务;
    • 线程数量已达到线程池规定的最大值,则拒绝执行此任务

如果条件为否:核心线程 > 任务列表 > 非核心线程 > 拒接任务。

AsyncTask的THREAD_POOL_EXECUTOR线程池配置参数:

  • 核心线程数:CPU_COUNT - 1,最小为2最大为4;
  • 线程池的最大线程数:CPU_COUNT * 2+ 1;
  • 核心线程无超时机制,非核心线程在闲置时的超时时间为30秒;
  • 任务队列的容量为128。

11.3.2 线程池的分类

  • FixedThreadPool:
    • 含义:线程数量固定的线程池,所有线程都是核心线程,当线程空闲时不会被回收。
    • 特点:能快速响应外界请求。
  • CachedThreadPool:
    • 含义:线程数量不定的线程池(最大线程数为Integer.MAX_VALUE),只有非核心线程,空闲线程有超时机制,60s超时回收。
    • 特点:适合于执行大量的耗时较少的任务
  • ScheduledThreadPool:
    • 含义:核心线程数量固定,非核心线程数量不定。
    • 特点:定时任务和固定周期的任务。
  • SingleThreadExecutor:
    • 含义:只有一个核心线程,可确保所有的任务都在同一个线程中按顺序执行。
    • 特点:无需处理线程同步问题。
2019/03/06 posted in  Android开发艺术探索

11 Dalvik和ART

11.1 Dalvik虚拟机

Dalvik虚拟机(Dalvik Virtual Machine ),简称Dalvik VM或者DVM。DVM是Google专门为Android平台开发的虚拟机,它运行在Android运行时库中。需要注意的是DVM并不是一个Java 虚拟机(以下简称JVM)。

11.1.1 DVM与JVM的区别

DVM之所以不是一个 JVM,主要原因是DVM并没有遵循JVM规范来实现, DVM与JVM主要有以下区别。

  1. 基于的架构不同
    JVM基于栈则意味着需要去栈中读写数据,所需的指令会更多,这样会导致速度变慢,对于性能有限的移动设备,显然不是很适合的。DVM是基于寄存器的,它没有基于栈的虚拟机在复制数据时而使用的大量的出入栈指令,同时指令更紧凑、更简洁。但是由于显式指定了操作数,所以基于寄存器的指令会比基于栈的指令要大,但是由于指令数量的减少,总的代码数不会增加多少。

  2. 执行的字节码不同
    在Java SE程序中,Java类被编译成一个或多个.class文件,并打包成jar文件,而后JVM会通过相应的.class文件和jar文件获取相应的字节码。执行顺序为.java文件→.class 文件→.jar文件。
    而DVM会用dx工具将所有的.class文件转换为一个.dex文件,然后DVM会从该dex文件读取指令和数据。执行顺序为.java文件→.class文件一→.dex文件。

    jar 文件里面包含多个.class文件,每个.class文件里面包含了该类的常量池、类信息、属性等。当JVM加载该jar文件的时候,会加载里面的所有的.class文件,JVM的这种加载方式很慢,对于内存有限的移动设备并不合适。而在.apk文件中只包含了一个.dex文件,这个.dex文件将所有的.class里面所包含的信息全部整合在一起了,这样再加载就加快了速度。.class 文件存在很多的冗余信息,dex 工具会去除冗余信息,并把所有的.class文件整合到.dex文件中,减少了IO操作,加快了类的查找速度。

  3. DVM允许在有限的内存中同时运行多个进程
    DVM经过优化,允许在有限的内存中同时运行多个进程。在Android中的每一个应用都运行在一个DVM实例中,每一个DVM实例都运行在一个独立的进程空间中,独立的进程可以防止在虚拟机崩溃的时候所有程序都被关闭。

  4. DVM由Zygote创建和初始化
    我们在第2章学习过Zygote,它是一个DVM进程,同时也用来创建和初始化DVM实例。每当系统需要创建一个应用程序时 ,Zygote 就会fock 自身,快速地创建和初始化一个DVM实例,用于应用程序的运行。对于一些只读的系统库,所有的DVM实例都会和Zygote共享一块内存区域,节省了内存开销。

  5. DVM有共享机制.
    DVM拥有预加载一共 享的机制,不同应用之间在运行时可以共享相同的类,拥有更 高的效率。而JVM机制不存在这种共享机制,不同的程序,打包以后的程序都是彼此独立的,即便它们在包里使用了同样的类,运行时也都是单独加载和运行的,无法进行共享。

  6. DVM早期没有使用JIT编译器
    JVM使用了JIT编译器(Just In Time Compiler,即时编译器),而DVM早期没有使用JIT编译器。早期的DVM每次执行代码,都需要通过解释器将dex代码编译成机器码,然后交给系统处理,效率不是很高。为了解决这一问题,从Android 2.2版本开始DVM使用了JIT 编译器,它会对多次运行的代码(热点代码)进行编译,生成相当精简的本地机器码(Native Code),这样在下次执行到相同逻辑的时候,直接使用编译之后的本地机器码,而不是每次都需要编译。需要注意的是,应用程序每一次重新运行的时候,都要重做这个编译工作,因此每次重新打开应用程序,都需要JIT编译。

11.1.2 DVM架构

首先Java编译器编译的.class文件经过DX工具转换为.dex文件,.dex文件由类加载器处理,接着解释器根据指令集对Dalvik字节码进行解释、执行,最后交于Linux处理。

11.1.3 DVM的运行时堆

DVM的运行时堆使用标记一清除(Mark一Sweep) 算法进行GC,它由两个Space以及多个辅助数据结构组成,两个Space分别是Zygote Space (Zygote Heap)和Allocation Space(Active Heap)。Zygote Space用来管理Zygote进程在启动过程中预加载和创建的各种对象,Zygote Space中不会触发GC,在Zygote进程和应用程序进程之间会共享Zygote Space。在Zygote进程fork第一个子进程之前,会把Zygote Space分为两个部分,原来的已经被使用的那部分堆仍旧叫Zygote Space, 而未使用的那部分堆就叫Allocation Space, 以后的对象都会在Allocation Space上进行分配和释放。Allocation Space不是进程间共享的,在每个进程中都独立拥有一份。除了这两个Space,还包含以下数据结构。

  • Card Table: 用于DVM Concurrent GC,当第一次进行垃圾标记后,记录垃圾信息。
  • Heap Bitmap: 有两个Heap Bitmap, 一个用来记录上次GC存活的对象,另一个用来记录这次GC存活的对象。
  • Mark Stack: DVM的运行时堆使用标记一清除(Mark一 Sweep)算法进行GC,MarkStack就是在GC的标记阶段使用的,它用来遍历存活的对象。

11.1.4 DVM的GC日志

在10.6.2节中提到了Java虚拟机的GC日志。DVM和ART的GC日志与Java虚拟机的日志有较大的区别。在DVM中每次垃圾收集都会将GC日志打印到logcat中,具体的格式为:

D/dalvikvm: <GC Reason> <Amount freed>, <Heap stats>,<External memory stats>,<Pause t ime>

可以看到DVM的日志共有5个信息,其中GCReason有很多种,这里将它单独拿出来进行介绍。

1.引起GC的原因
GC Reason就是引起GC的原因,有以下几种。

  • GC_CONCURRENT: 当堆开始填充时,并发GC可以释放内存。
  • GC_FOR_MALLOC:当堆内存已满时,App尝试分配内存而引起的GC,系统必须停止App并回收内存。
  • GC_HPROF_DUMP_HEAP:当你请求创建HPROF文件来分析堆内存时出现的GC。
  • GC_EXPLICIT: 显式的GC,例如调用System.gc() (应该避免调用显式的GC,信任GC会在需要时运行)。
  • GC_EXTERNAL_ALLOC: 仅适用于API级别小于等于10, 且用于外部分配内存的GC。

2.其他的信息
除了引起GC的原因,其他的信息如下。

  • Amount freed: 本次GC释放内存的大小。
  • Heap_stats: 堆的空闲内存百分比(已用内存) / (堆的总内存)。
  • External_memory_stats: API小于等于级别10的内存分配(已分配的内存) / (引起GC的阈值)。
  • Pausetime:暂停时间,更大的堆会有更长的暂停时间。并发暂停时间会显示两个暂停时间,即一个出现在垃圾收集开始时,另一个出现在垃圾收集快要完成时。

3.实例分析

D/dalvikvm: GC CONCURRENT freed 2012K, 63号 free 3213K/9291K, external 4501K/5161K,paused 2ms+2ms

这个GC日志的含义为:引起GC的原因是GC_CONCURRENT;本次GC释放的内存为2012KB; 堆的空闲内存百分比为63%, 已用内存为3213KB,堆的总内存为9291KB;暂停的总时长为4ms。

11.2 ART虚拟机

ART (Android Runtime)虚拟机是Android 4.4发布的,用来替换Dalvik虚拟机,Android4.4默认采用的还是DVM,系统会提供一 个选项来开启ART。在Android 5.0版本中默认采用了ART, DVM从此退出历史舞台。

11.2.1 ART与DVM的区别

  1. 从11.1节我们知道,DVM中的应用每次运行时,字节码都需要通过JIT编译器编译为机器码,这会使得应用程序的运行效率降低。而在ART中,系统在安装应用程序时会进行一次AOT (ahead of time compilation, 预编译),将字节码预先编译成机器码并存储在本地,这样应用程序每次运行时就不需要执行编译了,运行效率会大大提升,设备的耗电量也会降低。这就好比我们在线阅读漫画,DVM是我们阅读到哪就加载哪,ART则是直接加载一章的漫画,虽然一开始加载速度有些慢,但是后续的阅读体验会很流畅。采用AOT也会有缺点,主要有两个:第一个是AOT会使得应用程序的安装时间变长,尤其是一些复杂的应用;第二个是字节码预先编译成机器码,机器码需要的存储空间会多一些。为了解决上面的缺点,Android 7.0版本中的ART加入了即时编译器JIT,作为AOT的一个补充,在应用程序安装时并不会将字节码全部编译成机器码,而是在运行中将热点代码编译成机器码,从而缩短应用程序的安装时间并节省了存储空间。
  2. DVM是为32位CPU设计的,而ART支持64位并兼容32位CPU,这也是DVM被淘汰的主要原因之一。
  3. ART对垃圾回收机制进行了改进,比如更频繁地执行并行垃圾收集,将GC暂停由2次减少为1次等。
  4. ART的运行时堆空间划分和DVM不同。

11.2.2 ART的运行时堆

与DVM的GC不同的是,ART采用了多种垃圾收集方案,每个方案会运行不同的垃圾收集器,默认是采用了CMS(Concurrent Mark一Sweep)方案,该方案主要使用了sticky一CMS和partial一CMS。根据不同的CMS方案,ART的运行时堆的空间也会有不同的划分,默认是由4个Space和多个辅助数据结构组成的,4个Space分别是Zygote Space、AllocationSpace、Image Space和Large Object Space。Zygote Space、Allocation Space和DVM中的作用是一样的,Image Space用来存放一些预加载类, Large Object Space用来分配一些大对象(默认大小为12KB),其中Zygote Space和Image Space是进程间共享的。采用标记一清除算法的运行时堆空间划分如图11一3所示。

除了这四个Space,ART的Java堆中还包括两个Mod Union Table, 一个Card Table,两个Heap Bitmap,两个Object Map,以及三个Object Stack。

11.2.3 ART的GC日志

ART的GC日志与DVM不同,ART会为那些主动请求的垃圾收集事件或者认为GC速度慢时才会打印GC日志。GC速度慢指的是GC暂停超过5ms或者GC持续时间超过100ms。如果App未处于可察觉的暂停进程状态,那么它的GC不会被认为是慢速的。
ART的GC日志具体的格式为:

I/art: <GC_Reason> <GC_Name> <0bjects_freed> (<Size_freed>) AllocSpace Objects,<Large_objects_freed> (<Large_object_size_freed>) <Heap_stats> LOS objects,
<Pause time(s)>

1.引起GC原因
ART的引起GC原因(GC_Reason) 要比DVM多一些,有以下几种。

  • Concurrent: 并发GC,不会使App的线程暂停,该GC是在后台线程运行的,并不会阻止内存分配。
  • Alloc: 当堆内存已满时,App尝试分配内存而引起的GC,这个GC会发生在正在.分配内存的线程中。
  • Explicit: App 显示的请求垃圾收集,例如调用System.gc()。与DVM一样,最佳做法是应该信任GC并避免显式地请求GC,显式地请求GC会阻止分配线程并不必要地浪费CPU周期。如果显式地请求GC导致其他线程被抢占,那么有可能会导致jank(App同一帧画了多次)。
  • NativeAlloc: Native 内存分配时,比如为Bitmaps或者RenderScript分配对象,这会导致Native内存压力,从而触发GC。
  • CollectorTransition: 由堆转换引起的回收,这是运行时切换GC而引起的。收集器转换包括将所有对象从空闲列表空间复制到碰撞指针空间(反之亦然)。 当前,收集器转换仅在以下情况下出现:在内存较小的设备.上,App将进程状态从可察觉的暂停状态变更为可察觉的非暂停状态(反之亦然)。
  • HomogeneousSpaceCompact: 齐性空间压缩是指空闲列表到压缩的空闲列表空间,通常发生在当App已经移动到可察觉的暂停进程状态时。这样做的主要原因是减少了内存使用并对堆内存进行碎片整理。
  • DisableMovingGc: 不是真正触发GC的原因,发生并发堆压缩时,由于使用了GetPrimitiveArrayCritical,收集会被阻塞。在一般情况下,强烈建议不要使用GetPrimitiveArrayCritical,因为它在移动收集器方面具有限制。
  • HeapTrim: 不是触发GC的原因,但是请注意,收集会一直被阻塞,直到堆内存整理完毕。

2.垃圾收集器名称
GC_Name指的是垃圾收集器名称,有以下几种。

  • Concurrent Mark Sweep (CMS):CMS 收集器是一种以获取最短收集暂停时间为目标的收集器,采用了标记一清除算法实现。它是完整的堆垃圾收集器,能释放除了Image Space外的所有的空间。
  • Concurrent Partial Mark Sweep:部分完整的堆垃圾收集器,能释放除了Image Space和Zygote Space外的所有空间。
  • Concurrent Sticky Mark Sweep:粘性收集器,基于分代的垃圾收集思想,它只能释放自.上次GC以来分配的对象。这个垃圾收集器比一个完整的或部分完整的垃圾收集器扫描得更频繁,因为它更快并且有更短的暂停时间。
  • Marksweep + Semispace:非并发的GC,复制GC用于堆转换以及齐性空间压缩(堆碎片整理)。

3.其他信息

  • Objects freed:本次GC从非Large Object Space中回收的对象的数量。
  • Size_ freed: 本次GC从非Large Object Space中回收的字节数。
  • Large objects freed:本次GC从Large Object Space中回收的对象的数量。
  • Large object size freed:本次GC从Large Object Space中回收的字节数。
  • Heap stats:堆的空闲内存百分比,即(已用内存) / (堆的总内存)。
    Pause times: 暂停时间, 暂停时间与在GC运行时修改的对象引用的数量成比例。目前,ART的CMS收集器仅有一次暂停,它出现在GC的结尾附近。移动的垃圾收集器暂停时间会很长,会在大部分垃圾回收期间持续出现。

4.实例分析

I/art : Explicit concurrent mark sweep GC freed 104710 (7MB) AllocSpace objects,21 (416KB) LOS objects, 33号free, 25MB/ 38MB, paused 1.230ms total 67.21 6ms

这个GC日志的含义为引起GC原因是Explicit; 垃圾收集器为CMS收集器;释放对象的数量为104710个,释放字节数为7MB;释放大对象的数量为21个,释放大对象字节数为416KB;堆的空闲内存百分比为33%,已用内存为25MB,堆的总内存为38MB; GC暂停时长为1.230ms, GC总时长为67.216ms。

11.3 DVM和ART的诞生

11.4 本章小结

2019/03/27 posted in  Android进阶解密

11 | Java线程(下):为什么局部变量是线程安全的?

  • 方法是如何被执行的
  • 局部变量存哪里?
  • 调用栈与线程
  • 线程封闭

方法是如何被执行的

int a = 7;
int[] b = fibonacci(a);
int[] c = b;

当调用 fibonacci(a) 的时候,CPU 要先找到方法 fibonacci() 的地址,然后跳转到这个地址去执行代码,最后 CPU 执行完方法 fibonacci() 之后,要能够返回。首先找到调用方法的下一条语句的地址:也就是int[] c=b;的地址,再跳转到这个地址去执行。

“CPU 去哪里找到调用方法的参数和返回地址?”

通过 CPU 的堆栈寄存器。CPU 支持一种栈结构,先入后出。因为这个栈是和方法调用相关的,因此经常被称为调用栈。

例如,有三个方法 A、B、C,他们的调用关系是 A->B->C(A 调用 B,B 调用 C),在运行时,会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间,称为栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,栈帧和方法是同生共死的

局部变量存哪里?

局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。此时你应该会想到调用栈的栈帧,调用栈的栈帧就是和方法同生共死的,所以局部变量放到调用栈里那儿是相当的合理。事实上,的确是这样的,局部变量就是放到了调用栈里。

Java中new 出来的对象是在堆里,局部变量是在栈里。局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。

调用栈与线程

两个线程可以同时用不同的参数调用相同的方法,那调用栈和线程之间是什么关系呢?答案是:每个线程都有自己独立的调用栈。因为如果不是这样,那两个线程就互相干扰了。如下面这幅图所示,线程 A、B、C 每个线程都有自己独立的调用栈。

现在,让我们回过头来再看篇首的问题:Java 方法里面的局部变量是否存在并发问题?现在你应该很清楚了,一点问题都没有。因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。

线程封闭

方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做线程封闭,比较官方的解释是:仅在单线程内访问数据

采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接 Connection,在 JDBC 规范里并没有要求这个 Connection 必须是线程安全的。数据库连接池通过线程封闭技术,保证一个 Connection 一旦被一个线程获取之后,在这个线程关闭 Connection 之前的这段时间里,不会再分配给其他线程,从而保证了 Connection 不会有并发问题。

课后思考

常听人说,递归调用太深,可能导致栈溢出。你思考一下原因是什么?有哪些解决方案呢?

答:
栈溢出原因:
因为每调用一个方法就会在栈上创建一个栈帧,方法调用结束后就会弹出该栈帧,而栈的大小不是无限的,所以递归调用次数过多的话就会导致栈溢出。而递归调用的特点是每递归一次,就要创建一个新的栈帧,而且还要保留之前的环境(栈帧),直到遇到结束条件。所以递归调用一定要明确好结束条件,不要出现死循环,而且要避免栈太深。
解决方法:

  1. 简单粗暴,不要使用递归,使用循环替代。缺点:代码逻辑不够清晰;
  2. 限制递归次数;
  3. 使用尾递归,尾递归是指在方法返回时只调用自己本身,且不能包含表达式。编译器或解释器会把尾递归做优化,使递归方法不论调用多少次,都只占用一个栈帧,所以不会出现栈溢出。然鹅,Java没有尾递归优化。
2019/12/05 posted in  极客-Java并发实战

11 | 垃圾回收(上)

2019/12/14 posted in  极客-深入拆解Java虚拟机

11 命令模式

请求发送者与接收者解耦——命令模式

命令模式(Command Pattern)是一种数据驱动的设计模式,它属于行为型模式。请求以命令的形式包裹在对象中,并传给调用对象。调用对象寻找可以处理该命令的合适的对象,并把该命令传给相应的对象,该对象执行命令。

介绍

意图:将一个请求封装成一个对象,从而使您可以用不同的请求对客户进行参数化。
主要解决:在软件系统中,行为请求者与行为实现者通常是一种紧耦合的关系,但某些场合,比如需要对行为进行记录、撤销或重做、事务等处理时,这种无法抵御变化的紧耦合的设计就不太合适。
何时使用:在某些场合,比如要对行为进行"记录、撤销/重做、事务"等处理,这种无法抵御变化的紧耦合是不合适的。在这种情况下,如何将"行为请求者"与"行为实现者"解耦?将一组行为抽象为对象,可以实现二者之间的松耦合。
如何解决:通过调用者调用接受者执行命令,顺序:调用者→接受者→命令。
关键代码:定义三个角色:1、received 真正的命令执行对象 2、Command 3、invoker 使用命令对象的入口
应用实例:struts 1 中的 action 核心控制器 ActionServlet 只有一个,相当于 Invoker,而模型层的类会随着不同的应用有不同的模型类,相当于具体的 Command。
优点: 1、降低了系统耦合度。 2、新的命令可以很容易添加到系统中去。
缺点:使用命令模式可能会导致某些系统有过多的具体命令类。
使用场景:认为是命令的地方都可以使用命令模式,比如: 1、GUI 中每一个按钮都是一条命令。 2、模拟 CMD。
注意事项:系统需要支持命令的撤销(Undo)操作和恢复(Redo)操作,也可以考虑使用命令模式,见命令模式的扩展。

2019/11/01 posted in  菜鸟-设计模式

11 排序上:为什么插入排序比冒泡排序更受欢迎?

最经典的、最常用的:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。

如何分析一个“排序算法”?

  1. 排序算法的执行效率
    1. 最好情况、最坏情况、平均情况时间复杂度
    2. 时间复杂度的系数、常数 、低阶
    3. 比较次数和交换(或移动)次数
  2. 排序算法的内存消耗
  3. 排序算法的稳定性

原地排序算法,就是特指空间复杂度是 O(1) 的排序算法。

稳定性:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。

冒泡排序(Bubble Sort)

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。

经过一次冒泡操作之后,6 这个元素已经存储在正确的位置上。要想完成所有数据的排序,我们只要进行 6 次这样的冒泡操作就行了。

// 冒泡排序,a表示数组,n表示数组大小
public void bubbleSort(int[] a, int n) {
  if (n <= 1) return;
 
 for (int i = 0; i < n; ++i) {
    // 提前退出冒泡循环的标志位
    boolean flag = false;
    for (int j = 0; j < n - i - 1; ++j) {
      if (a[j] > a[j+1]) { // 交换
        int tmp = a[j];
        a[j] = a[j+1];
        a[j+1] = tmp;
        flag = true;  // 表示有数据交换      
      }
    }
    if (!flag) break;  // 没有数据交换,提前退出
  }
}

有序度:数组中具有有序关系的元素对的个数。满有序度:完全有序。
逆序度:逆序度的定义正好跟有序度相反。
逆序度 = 满有序度 - 有序度
我们排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了。

冒泡排序包含两个操作原子,比较交换。每交换一次,有序度就加 1。不管算法怎么改进,交换次数总是确定的,即为逆序度,也就是n*(n-1)/2–初始有序度

插入排序

一个有序的数组,我们往里面添加一个新的数据后,如何继续保持数据有序呢?很简单,我们只要遍历数组,找到数据应该插入的位置将其插入即可。

插入排序具体是如何借助上面的思想来实现排序的呢?

我们将数组中的数据分为两个区间,已排序区间未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。

插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。当我们需要将一个数据 a 插入到已排序区间时,需要拿 a 与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,我们还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素 a 插入。

对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。

// 插入排序,a表示数组,n表示数组大小
public void insertionSort(int[] a, int n) {
  if (n <= 1) return;

  for (int i = 1; i < n; ++i) {
    int value = a[i];
    int j = i - 1;
    // 查找插入的位置
    for (; j >= 0; --j) {
      if (a[j] > value) {
        a[j+1] = a[j];  // 数据移动
      } else {
        break;
      }
    }
    a[j+1] = value; // 插入数据
  }
}

选择排序

选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。

解答开篇

问:冒泡排序和插入排序的时间复杂度都是 O(n2),都是原地排序算法,为什么插入排序要比冒泡排序更受欢迎呢?

答:冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。

总结

2019/05/17 posted in  极客-数据结构与算法之美

11 晚期(运行期)优化

11.1概述329

11.2HotSpot虚拟机内的即时编译器329

11.2.1解释器与编译器330

11.2.2编译对象与触发条件332

11.2.3编译过程337

11.2.4查看及分析即时编译结果339

11.3编译优化技术345

11.3.1优化技术概览346

11.3.2公共子表达式消除350

11.3.3数组边界检查消除351

11.3.4方法内联352

11.3.5逃逸分析354

11.4Java与C/C++的编译器对比356

11.5本章 小结358

2019/11/28 posted in  深入理解虚拟机

11 系统架构与MediaPlayer框架

2019/03/27 posted in  Android进阶之光

11事件处理

2019/03/28 posted in  Java核心技术

12 Bitmap的加载和Cache

12.1 Bitmap的高效加载

BitmapFactory类提供了四类方法:decodeFile、decodeResource、decodeStream和decodeByteArray,分别用于支持从文件系统、资源、输入流以及字节数组中加载出一个Bitmap对象,其中decodeFile和decodeResource又间接调用了decodeStream方法,这四类方法最终是在Android的底层实现的,对应着BitmapFactory类的几个native方法。

如何高效地加载Bitmap 呢?其实核心思想也很简单,那就是采用BitmapFactory.Options来加载所需尺寸的图片。通过BitmapFactory.Options来缩放图片,主要是用到了它的inSampleSize参数,即采样率。

通过采样率即可有效地加载图片,那么到底如何获取采样率呢?获取采样率也很简单,遵循如下流程:

  1. 将BitmapFactory.Options的inJustDecodeBounds参数设为true并加载图片。
  2. 从BitmapFactory.Options 中取出图片的原始宽高信息,它们对应于outWidth 和outHeight参数。
  3. 根据采样率的规则并结合目标View的所需大小计算出采样率inSampleSize。
  4. 将BitmapFactory.Options的inJustDecodeBounds 参数设为false, 然后重新加载图片。

这里说明一下inJustDecodeBounds参数,当此参数设为true时,BitmapFactory 只会解析图片的原始宽/高信息,并不会去真正地加载图片,所以这个操作是轻量级的。

12.2 Android中的缓存策略

缓存策略:内次缓存+存储缓存。当应用打算从网络上请求一张图片时,程序首先从内存中去获取,如果内存中没有那就从存储设备中去获取,如果存储设备中也没有,那就从网络上下载这张图片。

一般来说, 缓存策略主要包含缓存的添加、获取和删除这三类操作。删除缓存目前常用的一种缓存算法是LRU(Least Recently Used),LRU是近期最少使用算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。采用LRU算法的缓存有两种:LruCache和DiskLruCache,LruCache用于实现内存缓存,而DiskLruCache则充当了存储设备缓存。

12.2.1 LruCache

LruCache它是一个泛型类,它内部采用一个LinkedHashMap,当强引用的方式存储外界的缓存对象,其提供了get和put方法来完成缓存的获取和添加操作,当缓存满时,LruCache会移除较早使用的缓存对象,然后再添加新的缓存对象。

  • 强引用:直接的对象引用
  • 软引用:当一个对象只有软引用存在时,系统内存不足时此对象会被gc回收。
  • 弱引用:当一个对象只有弱引用存在时,此对象会随时被gc回收。

LruCache是线程安全的,因为用到了LinkedHashMap。

  • sizeOf 计算缓存对象的大小
  • LruCache.get(key) 获取一个缓存对象
  • LruCache.put(key, bitmap) 添加一个缓存对象
  • LruCache.remove删除一个指定的缓存对象。
  • entryRemoved 移除旧缓存时会调用
    • 可以进行一些资源回收工作

12.2.2 DiskLruCache

DiskLruCache用于实现存储设备缓存,即磁盘存储,它通过将缓存对象写入文件系统从而实现缓存的效果。

  1. DiskLruCache 的创建 open
  2. DiskLruCache 的缓存添加 edit commit abort
  3. DiskLruCache的缓存查找和删除 get remove delete

12.2.3 ImageLoader的实现

一般来说,一个优秀的ImageLoader应该具备如下功能:

  • 图片的同步加载
  • 图片的异步加载
  • 图片压缩
  • 内存缓存
  • 磁盘缓存
  • 网络拉取

实现步骤:

  1. 图片压缩功能的实现
    1. 根据宽高修改BitmapFactory.Options采样率
  2. 内存缓存和磁盘缓存的实现
    1. LruCache和DiskLruCache
  3. 同步加载和异步加载接口的设计
    1. 使用线程池和Handler

12.3 ImageLoader的使用

12.3.1 照片墙效果

12.3.2 优化列表的卡顿现象

  • 不要在getView中执行耗时操作。
  • 控制异步任务的执行频率。可以考虑在列表滑动的时候,停止加载图片,尽管这个过程是异步的,等列表停下来以后在加载图片仍然可以获得良好的用户体验。
  • 开启硬件加速可以解决莫名的卡顿问题,通过设置android:hardwareAccelerated = "true"即可为Activity开启硬件加速。
2019/03/06 posted in  Android开发艺术探索

12 Java内存模型与线程

2019/11/28 posted in  深入理解虚拟机

12 | 垃圾回收(下)

2019/12/14 posted in  极客-深入拆解Java虚拟机

12 | 如何用面向对象思想写好并发程序?

  • 一、封装共享变量
  • 二、识别共享变量间的约束条件
  • 三、制定并发访问策略

一、封装共享变量

面向对象思想里面有一个很重要的特性是封装,封装的通俗解释就是将属性和实现细节封装在对象内部,外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性。

利用面向对象思想写并发程序的思路:将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略

实际工作中,经常要面临的情况往往是有很多的共享变量,对于一些不会发生变化的共享变量,建议你用 final 关键字来修饰。这样既能避免并发问题,也能很明了地表明你的设计意图。

二、识别共享变量间的约束条件

识别共享变量间的约束条件非常重要。因为这些约束条件,决定了并发访问策略

例如,库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限。但是容易忽视了一个约束条件,就是库存下限要小于库存上限

public class SafeWM {
  // 库存上限
  private final AtomicLong upper =
        new AtomicLong(0);
  // 库存下限
  private final AtomicLong lower =
        new AtomicLong(0);
  // 设置库存上限
  void setUpper(long v){
    // 检查参数合法性
    if (v < lower.get()) {
      throw new IllegalArgumentException();
    }
    upper.set(v);
  }
  // 设置库存下限
  void setLower(long v){
    // 检查参数合法性
    if (v > upper.get()) {
      throw new IllegalArgumentException();
    }
    lower.set(v);
  }
  // 省略其他业务代码
}

当你看到代码里出现 if 语句的时候,就应该立刻意识到可能存在竞态条件,导致并发问题。
在设计阶段,我们一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙

三、制定并发访问策略

制定并发访问策略,是一个非常复杂的事情。应该说整个专栏都是在尝试搞定它。不过从方案上来看,无外乎就是以下“三件事”。

  1. 避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
  2. 不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。
  3. 管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。

除了这些方案之外,还有一些宏观的原则需要你了解。这些宏观原则,有助于你写出“健壮”的并发程序。这些原则主要有以下三条。

  1. 优先使用成熟的工具类:Java SDK 并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便就能发明成功的。
  2. 迫不得已时才使用低级的同步原语:低级的同步原语主要指的是 synchronized、Lock、Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
  3. 避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的。

课后思考

本期示例代码中,类 SafeWM 不满足库存下限要小于库存上限这个约束条件,那你来试试修改一下,让它能够在并发条件下满足库存下限要小于库存上限这个约束条件。

答:

  1. setUpper() 跟 setLower() 都加上 "synchronized" 关键字。不要太在意性能,老师都说了,避免过早优化。
  2. 如果性能有问题,可以把 lower 跟 upper 两个变量封装到一个类中,例如

    public class Boundary {
    private final lower;
    private final upper;
    public Boundary(long lower, long upper) {
    if(lower >= upper) {
    // throw exception
    }
    this.lower = lower;
    this.upper = upper;
    }
    }

    移除 SafeVM 的 setUpper() 跟 setLower() 方法,并增入 setBoundary(Boundary boundary) 方法。

2019/12/05 posted in  极客-Java并发实战

12 排序下:如何用快排思想在O(n)内查找第K大元素?

归并排序和快速排序都用到了分治思想。

归并排序的原理

如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。

归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。

分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧。

归并排序的递推公式和终止条件:

递推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))

终止条件:
p >= r 不用再继续分解

伪代码:

// 归并排序算法, A是数组,n表示数组大小
merge_sort(A, n) {
  merge_sort_c(A, 0, n-1)
}

// 递归调用函数
merge_sort_c(A, p, r) {
  // 递归终止条件
  if p >= r  then return

  // 取p到r之间的中间位置q
  q = (p+r) / 2
  // 分治递归
  merge_sort_c(A, p, q)
  merge_sort_c(A, q+1, r)
  // 将A[p...q]和A[q+1...r]合并为A[p...r]
  merge(A[p...r], A[p...q], A[q+1...r])
}

merge()函数过程和伪代码

merge(A[p...r], A[p...q], A[q+1...r]) {
  var i := p,j := q+1,k := 0 // 初始化变量i, j, k
  var tmp := new array[0...r-p] // 申请一个大小跟A[p...r]一样的临时数组
  while i<=q AND j<=r do {
    if A[i] <= A[j] {
      tmp[k++] = A[i++] // i++等于i:=i+1
    } else {
      tmp[k++] = A[j++]
    }
  }
  
  // 判断哪个子数组中有剩余的数据
  var start := i,end := q
  if j<=r then start := j, end:=r
  
  // 将剩余的数据拷贝到临时数组tmp
  while start <= end do {
    tmp[k++] = A[start++]
  }
  
  // 将tmp中的数组拷贝回A[p...r]
  for i:=0 to r-p do {
    A[p+i] = tmp[i]
  }
}

归并排序的性能分析

  • 归并排序是一个稳定的排序算法
  • 归并排序的时间复杂度是O(nlogn)
  • 归并排序的空间复杂度是O(n)

缺点:归并排序不是原地排序算法。

快速排序的原理

如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。

根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。

递推公式和终止条件:

递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1… r)

终止条件:
p >= r

伪代码:

// 快速排序,A是数组,n表示数组的大小
quick_sort(A, n) {
  quick_sort_c(A, 0, n-1)
}
// 快速排序递归函数,p,r为下标
quick_sort_c(A, p, r) {
  if p >= r then return
  
  q = partition(A, p, r) // 获取分区点
  quick_sort_c(A, p, q-1)
  quick_sort_c(A, q+1, r)
}

原地分区函数partition()伪代码:

partition(A, p, r) {
  pivot := A[r]
  i := p
  for j := p to r-1 do {
    if A[j] < pivot {
      swap A[i] with A[j]
      i := i+1
    }
  }
  swap A[i] with A[r]
  return i

过程如图:

快速排序的性能分析

  • 快排是一种原地、不稳定的排序算法。
  • 时间复杂度最差情况:T(n) = O(n2);平均情况:T(n) = O(nlogn)。

归并排序和快速排序的异同:

  • 归并排序的处理过程是由下到上的,先处理子问题,然后再合并。
  • 而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。
  • 归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。
  • 快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。

内容小结

  • 归并排序和快速排序是两种稍微复杂的排序算法,它们用的都是分治的思想,代码都通过递归来实现,过程非常相似。理解归并排序的重点是理解递推公式和 merge() 合并函数。同理,理解快排的重点也是理解递推公式,还有 partition() 分区函数。
  • 归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法,这也使它存在致命的缺点,即归并排序不是原地排序算法,空间复杂度比较高,是 O(n)。正因为此,它也没有快排应用广泛。
  • 快速排序算法虽然最坏情况下的时间复杂度是 O(n2),但是平均情况下时间复杂度都是 O(nlogn)。不仅如此,快速排序算法时间复杂度退化到 O(n2) 的概率非常小,我们可以通过合理地选择 pivot 来避免这种情况。
2019/05/17 posted in  极客-数据结构与算法之美

12 理解ClassLoader

12.1 Java中的ClassLoader

类加载子系统,主要作用就是通过多种类加载器(ClassLoader)来查找和加载Class文件到Java虚拟机中。

12.1.1 ClassLoader的类型

  • 系统类加载器
    • Bootstrap ClassLoader
    • Extensions ClassLoader
    • Application ClassLoader
  • 自定义类加载器

Bootstrap ClassLoader 引导类加载器
C/C++代码实现的加载器,用于加载指定的JDK核心类库,比如java.lang.java.util.等系统类。它用来加载以下目录中的类库 :

  • $JAVA HOME/jre/lib 目录。
  • -Xbootclasspath 参数指定的目录 。
    Java 虚拟机的启动就是通过 Bootstrap ClassLoader 创建一个初始类来完成的。

Extensions ClassLoader
Java 中的实现类为 ExtClassLoader,因此可以简称为ExtClassLoader,它用于加载 Java的拓展类,提供除了系统类之外的额外功能。 ExtClassLoader用来加载以下目录中的类库:

  • 加载$JAVA_HOME/jre/lib/ext 目录。
  • 系统属性 java.ext.dir所指定的目录。

Application ClassLoader
简称为APPClassLoader,又称为System ClassLoader(系统类加载器),它用来加载以下目录的类库:

  • 当前程序的Classpath目录。
  • 系统属性java.class.path指定的目录。

12.1.2 ClassLoader的继承关系

12.1.3 双亲委托模式

双亲委托模型:

  1. 首先判断该Class是否已经加载,如果没有则委托父加载器进行查找
  2. 依次进行递归,直到委托到最顶层的Bootstrap ClassLoader
  3. 如果Bootstrap ClassLoader找到了该Class,直接返回;如果没有找到,则继续依次向下查找,如果还没找到最后交由自身去查找。

双亲委托模型的好处:

  • 避免重复加载。
  • 更加安全。比如无法通过自定义String类替代系统的String类。

12.1.4 自定义ClassLoader

系统提供的类加载器只能够加载指定目录下的 jar 包和 Class 文件,如果想要加载网络上的或者 D 盘某一文件中的 jar 包和 Class 文件则需要自定义 ClassLoader。实现自定义ClassLoader 需要如下两个步骤:

  1. 定义一个自定义 ClassLoade 并继承抽象类 ClassLoader。
  2. 复写 findClass 方法,并在 findClass 方提中调用 defineClass 方法。

12.2 Android中的ClassLoader

12.2.1 ClassLoader的类型

12.2.2 ClassLoader的继承关系

12.2.3 ClassLoader的加载过程

12.2.4 BootClassLoader的创建

12.2.5 PathClassLoader的创建

12.3 本章小结 311

2019/03/27 posted in  Android进阶解密

12 观察者模式

对象间的联动——观察者模式

当对象间存在一对多关系时,则使用观察者模式(Observer Pattern)。比如,当一个对象被修改时,则会自动通知它的依赖对象。观察者模式属于行为型模式。

介绍

意图:定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。
主要解决:一个对象状态改变给其他对象通知的问题,而且要考虑到易用和低耦合,保证高度的协作。
何时使用:一个对象(目标对象)的状态发生改变,所有的依赖对象(观察者对象)都将得到通知,进行广播通知。
如何解决:使用面向对象技术,可以将这种依赖关系弱化。
关键代码:在抽象类里有一个 ArrayList 存放观察者们。
应用实例: 1、拍卖的时候,拍卖师观察最高标价,然后通知给其他竞价者竞价。 2、西游记里面悟空请求菩萨降服红孩儿,菩萨洒了一地水招来一个老乌龟,这个乌龟就是观察者,他观察菩萨洒水这个动作。
优点: 1、观察者和被观察者是抽象耦合的。 2、建立一套触发机制。
缺点: 1、如果一个被观察者对象有很多的直接和间接的观察者的话,将所有的观察者都通知到会花费很多时间。 2、如果在观察者和观察目标之间有循环依赖的话,观察目标会触发它们之间进行循环调用,可能导致系统崩溃。 3、观察者模式没有相应的机制让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。
使用场景:

  • 一个抽象模型有两个方面,其中一个方面依赖于另一个方面。将这些方面封装在独立的对象中使它们可以各自独立地改变和复用。
  • 一个对象的改变将导致其他一个或多个对象也发生改变,而不知道具体有多少对象将发生改变,可以降低对象之间的耦合度。
  • 一个对象必须通知其他对象,而并不知道这些对象是谁。
  • 需要在系统中创建一个触发链,A对象的行为将影响B对象,B对象的行为将影响C对象……,可以使用观察者模式创建一种链式触发机制。

注意事项: 1、JAVA 中已经有了对观察者模式的支持类。 2、避免循环引用。 3、如果顺序执行,某一观察者错误会导致系统卡壳,一般采用异步方式。

2019/11/01 posted in  菜鸟-设计模式

12Swing用户界面组件

2019/03/28 posted in  Java核心技术

13 | Java内存模型

2019/12/14 posted in  极客-深入拆解Java虚拟机

13 | 理论基础模块热点问题答疑

起源是一个硬件的核心矛盾:CPU 与内存、I/O 的速度差异,系统软件(操作系统、编译器)在解决这个核心矛盾的同时,引入了可见性、原子性和有序性问题,这三个问题就是很多并发程序的 Bug 之源。这,就是01 | 可见性、原子性和有序性问题:并发编程Bug的源头的内容。

那如何解决这三个问题呢?Java 语言自然有招儿,它提供了 Java 内存模型和互斥锁方案。所以,在02 | Java内存模型:看Java如何解决可见性和有序性问题我们介绍了 Java 内存模型,以应对可见性和有序性问题;那另一个原子性问题该如何解决?多方考量用好互斥锁才是关键,这就是03 | 互斥锁(上):解决原子性问题04 | 互斥锁(下):如何用一把锁保护多个资源?的内容。

虽说互斥锁是解决并发问题的核心工具,但它也可能会带来死锁问题,所以05 | 一不小心就死锁了,怎么办?就介绍了死锁的产生原因以及解决方案;同时还引出一个线程间协作的问题,这也就引出了06 | 用“等待-通知”机制优化循环等待这篇文章的内容,介绍线程间的协作机制:等待 - 通知。

你应该也看出来了,前六篇文章,我们更多地是站在微观的角度看待并发问题。而07 | 安全性、活跃性以及性能问题则是换一个角度,站在宏观的角度重新审视并发编程相关的概念和理论,同时也是对前六篇文章的查漏补缺。

08 | 管程:并发编程的万能钥匙介绍的管程,是 Java 并发编程技术的基础,是解决并发问题的万能钥匙。并发编程里两大核心问题——互斥和同步,都是可以由管程来解决的。所以,学好管程,就相当于掌握了一把并发编程的万能钥匙。

至此,并发编程相关的问题,理论上你都应该能找到问题所在,并能给出理论上的解决方案了。

而后在09 | Java线程(上):Java线程的生命周期10 | Java线程(中):创建多少线程才是合适的?11 | Java线程(下):为什么局部变量是线程安全的?我们又介绍了线程相关的知识,毕竟 Java 并发编程是要靠多线程来实现的,所以有针对性地学习这部分知识也是很有必要的,包括线程的生命周期、如何计算合适的线程数以及线程内部是如何执行的。

最后,在12 | 如何用面向对象思想写好并发程序?我们还介绍了如何用面向对象思想写好并发程序,因为在 Java 语言里,面向对象思想能够让并发编程变得更简单。

2019/12/05 posted in  极客-Java并发实战

13 备忘录模式

撤销功能的实现——备忘录模式

备忘录模式(Memento Pattern)保存一个对象的某个状态,以便在适当的时候恢复对象。备忘录模式属于行为型模式。

介绍

意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。
主要解决:所谓备忘录模式就是在不破坏封装的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,这样可以在以后将对象恢复到原先保存的状态。
何时使用:很多时候我们总是需要记录一个对象的内部状态,这样做的目的就是为了允许用户取消不确定或者错误的操作,能够恢复到他原先的状态,使得他有"后悔药"可吃。
如何解决:通过一个备忘录类专门存储对象状态。
关键代码:客户不与备忘录类耦合,与备忘录管理类耦合。
应用实例:1、后悔药。 2、打游戏时的存档。 3、Windows 里的 ctri + z。 4、IE 中的后退。 4、数据库的事务管理。
优点:1、给用户提供了一种可以恢复状态的机制,可以使用户能够比较方便地回到某个历史的状态。 2、实现了信息的封装,使得用户不需要关心状态的保存细节。
缺点:消耗资源。如果类的成员变量过多,势必会占用比较大的资源,而且每一次保存都会消耗一定的内存。
使用场景: 1、需要保存/恢复数据的相关状态场景。 2、提供一个可回滚的操作。
注意事项: 1、为了符合迪米特原则,还要增加一个管理备忘录的类。 2、为了节约内存,可使用原型模式+备忘录模式。

2019/11/01 posted in  菜鸟-设计模式

13 热修复原理

13.1 热修复的产生

13.2 热修复框架的种类和对比

13.3 资源修复

13.3.1 Instant Run概述

Instant Run是Android Studio 2.0以后新增的一个运行机制, 能够显著减少开发人员第二次及以后的构建和部署时间。在没有使用Instant Run前,我们编译部署应用程序的流程如图所示。

从图13一1可以看出,传统的编译部署需要重新安装App和重启App,这显然会很耗时,Instant Run会避免这一情况, 如图所示。

从上图可以看出InstantRun的构建和部署都是基于更改的部分的。InstantRun部署有三种方式,Instant Run会根据代码的情况来决定采用哪种部署方式,无论哪种方式都不需要重新安装App,这一点就已经提高了不少的效率。

  • Hotswap:从名称也可以看出HotSwap是效率最高的部署方式,代码的增量改变不需要重启App,甚至不需要重启当前的Activity。 修改一个现有方法中的代码时会采用Hot Swap。
  • Warm Swap: App不需重启,但是Activity 需要重启。修改或删除一个现有的资源文件时会采用WarmSwap。
  • Cold Swap: App 需要重启,但是不需要重新安装。采用Cold Swap的情况很多,比如添加、删除或修改一个字段和方法、添加一个类等。

13.3.2 Instant Run的资源修复

Instant Run中的资源热修复可以简单地总结为两个步骤:

  1. 创建新的AssetManager, 通过反射调用addAssetPath方法加载外部的资源,这样新创建的AssetManager就含有了外部资源。
  2. 将AssetManager类型的mAssets字段的引用全部替换为新创建的AssetManager。

13.4 代码修复

代码修复主要有3个方案,分别是底层替换方案、类加载方案和Instant Run方案。

13.4.1 类加载方案

类加载方案基于Dex分包方案,什么是Dex分包方案呢?这个得先从65536限制和LinearAlloc限制说起。

1. 65536限制

随着应用功能越来越复杂,代码量不断地增大,引入的库也越来越多,可能会在编译时提示如下异常:

com. android. dex. DexIndexOverflowException: method ID not in [0, 0xffff] : 65536

这说明应用中引用的方法数超过了最大数65536个。产生这一问题的原因就是系统的65536限制,65536 限制的主要原因是DVM Bytecode的限制,DVM指令集的方法调用指令invoke-kind索引为16bits, 最多能引用65535个方法。

2. LinearAlloc 限制

在安装应用时可能会提示INSTALL_FAILED_DEXOPT,产生的原因就是LinearAlloc限制,DVM中的LinearAlloc是一个固定的缓存区,当方法数超出了缓存区的大小时会报错。

为了解决65536限制和LinearAlloc限制,从而产生了Dex分包方案。Dex 分包方案主要做的是在打包时将应用代码分成多个Dex,将应用启动时必须用到的类和这些类的直接引用类放到主Dex中,其他代码放到次Dex中。当应用启动时先加载主Dex,等到应用启动后再动态地加载次Dex,从而缓解了主Dex的65536限制和LinearAlloc限制。

Dex分包方案主要有两种,分别是Google官方方案、Dex自动拆包和动态加载方案。

在12.2.3节中学习了ClassLoader的加载过程,其中一个环节就是调用DexPathList 的findClass的方法,如下所示:

 public Class<?> findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {//1
            Class<?> clazz = element.findClass(name, definingContext, suppressed);//2
            if (clazz != null) {
                return clazz;
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }

Element内部封装了DexFile,DexFile用于加载dex文件,因此每个dex文件对应一个Element。

多个Element组成了有序的Element数组dexElements。当要查找类时,会在注释1处遍历Element数组dexElements(相当于遍历dex文件数组),注释2处调用Element的findClass方法,其方法内部会调用DexFile的loadClassBinaryName方法查找类。如果在Element中(dex文件)找到了该类就返回,如果没有找到就接着在下一个Element中进行查找。
根据上面的查找流程,我们将有bug的类Key.class进行修改,再将Key.class打包成包含dex的补丁包Patch.jar,放在Element数组dexElements的第一个元素,这样会首先找到Patch.dex中的Key.class去替换之前存在bug的Key.class,排在数组后面的dex文件中的存在bug的Key.class根据ClassLoader的双亲委托模式就不会被加载,这就是类加载方案,如下图所示。

类加载方案需要重启App后让ClassLoader重新加载新的类,为什么需要重启呢?这是因为类是无法被卸载的,因此要想重新加载新的类就需要重启App,因此采用类加载方案的热修复框架是不能即时生效的。
虽然很多热修复框架采用了类加载方案,但具体的实现细节和步骤还是有一些区别的:

  • QQ空间的超级补丁和Nuwa是按照上面说得将补丁包放在Element数组的第一个元素得到优先加载。
  • 微信Tinker将新旧apk做了diff,得到patch.dex,然后将patch.dex与手机中apk的classes.dex做合并,生成新的classes.dex,然后在运行时通过反射将classes.dex放在Element数组的第一个元素。
  • 饿了么的Amigo则是将补丁包中每个dex 对应的Element取出来,之后组成新的Element数组,在运行时通过反射用新的Element数组替换掉现有的Element 数组。

采用类加载方案的主要是以腾讯系为主,包括微信的Tinker、QQ空间的超级补丁、手机QQ的QFix、饿了么的Amigo和Nuwa等等。

13.4.2 底层替换方案

与类加载方案不同的是,底层替换方案不会再次加载新类,而是直接在Native层修改原有类,由于是在原有类进行修改限制会比较多,不能够增减原有类的方法和字段,如果我们增加了方法数,那么方法索引数也会增加,这样访问方法时会无法通过索引找到正确的方法,同样的字段也是类似的情况。

底层替换方案和反射的原理有些关联,就拿方法替换来说,

  1. 方法反射我们可以调用java.lang.Class.getDeclaredMethod.invoke()
  2. invoke方法是个native方法,对应Jni层的代码为:art/runtime/native/java_lang_reflect_Method.cc
  3. Method_invoke函数中又调用了InvokeMethod函数:art/runtime/reflection.cc
  4. 然后可以获取传入的javaMethod(Key的show方法)在ART虚拟机中对应的一个ArtMethod指针,ArtMethod结构体中包含了Java方法的所有信息,包括执行入口、访问权限、所属类和代码执行地址等等
  5. ArtMethod结构中比较重要的字段是dex_cache_resolved_methods_和的entry_point_from_quick_compiled_code_,它们是方法的执行入口,当我们调用某一个方法时(比如Key的show方法),就会取得show方法的执行入口,通过执行入口就可以跳过去执行show方法。替换ArtMethod结构体中的字段或者替换整个ArtMethod结构体,这就是底层替换方案。

AndFix采用的是替换ArtMethod结构体中的字段,这样会有兼容问题,因为厂商可能会修改ArtMethod结构体,导致方法替换失败。Sophix采用的是替换整个ArtMethod结构体,这样不会存在兼容问题。

底层替换方案直接替换了方法,可以立即生效不需要重启。采用底层替换方案主要是阿里系为主,包括AndFix、Dexposed、阿里百川、Sophix。

13.4.3 Instant Run方案

除了资源修复,代码修复同样也可以借鉴Instant Run的原理, 可以说Instant Run的出现推动了热修复框架的发展。
Instant Run在第一次构建apk时,使用ASM在每一个方法中注入了类似如下的代码:

IncrementalChange localIncrementalChange = $change;//1
        if (localIncrementalChange != null) {//2
            localIncrementalChange.access$dispatch(
                    "onCreate.(Landroid/os/Bundle;)V", new Object[] { this,
                            paramBundle });
            return;
        }
  1. 其中注释1处是一个成员变量localIncrementalChange ,它的值为\(change,\)change实现了IncrementalChange这个抽象接口。
  2. 当我们点击InstantRun时,如果方法没有变化则$change为null,就调用return,不做任何处理。
  3. 如果方法有变化,就生成替换类,
    1. 这里我们假设MainActivity的onCreate方法做了修改,就会生成替换类MainActivity$override,这个类实现了IncrementalChange接口,同时也会生成一个AppPatchesLoaderImpl类,
    2. 这个类的getPatchedClasses方法会返回被修改的类的列表(里面包含了MainActivity),
    3. 根据列表会将MainActivity的$change设置为MainActivity$override,因此满足了注释2的条件,会执行MainActivity$override的access$dispatch方法,
    4. accessdispatch方法中会根据参数'onCreate.(Landroid/os/Bundle;)V;',执行'MainActivityoverride'的onCreate方法,从而实现了onCreate方法的修改。

借鉴Instant Run的原理的热修复框架有Robust和Aceso。

13.5 动态链接库的修复

Android平台的动态链接库主要指的是so库,为了更好地理解,本章动态链接库简称为so。热修复框架的so的修复的主要是更新so,换句话说就是重新加载so,因此so的修复的基础原理就是加载。

13.5.1 System的load和loadLibarary方法

加载so主要用到了System类的load和loadLibarary方法。

  • System的load方法传入的参数是so在磁盘的完整路径,用于加载指定路径的so。
  • System的loadLibrary方法传入的参数是so的名称,用于加载App安装后自动从apk包中复制到/data/data/ packagename/lib下的so。

1. System的load方法

  1. Runtime.getRuntime()会得到当前Java应用程序的运行环境Runtime
  2. 调用了doLoad方法,并将加载该类的类加载器作为参数传入进去:
  3. doLoad方法会调用native方法nativeLoad

2. System的loadLibrary方法

  1. 先调用Runtime的loadLibrary0方法,loadLibrary0方法分为两个部分,一个是传入的ClassLoader不为null的部分,另一个是ClassLoader为null的部分
  2. 我们先来看ClassLoader 为null的部分。先遍历getLibPaths方法,这个方法会返回java.library.path选项配置的路径数组,然后拼接出so路径作为参数调用doLoad方法中。
  3. 当ClassLoader不为null时,先通过ClassLoader的findLibrary方法来得到fileName,然后调用doLoad 方法。
  4. findLibrary:ClassLoader的findLibrary方法在实现类BaseDexClassLoader中实现,然后会调用DexPathList的findLibrary方法,这和13.3.1 节讲到的DexPathList的findClass方法类似,
    1. 在NativeLibraryElement数组中的每一个NativeLibraryElement对应一个so库,
    2. 然后调用NativeLibraryElement的findNativeLibrary方法就可以返回so的路径。结合的类加载方案,就可以得到so的修复的一种方案,就是:
    3. 将so补丁插入到NativeLibraryElement数组的前部,让so补丁的路径先被返回,并调用Runtime的doLoad方法进行加载,
    4. 在doLoad方法中会调用native 方法nativeLoad。

13.5.2 nativeLoad方法分析

LoadNativeLibrary函数的行数很多,这里来做一个总结,LoadNativeLibrary 函数主要做了如下3方面工作:

  1. 判断so是否被加载过,两次ClassLoader是否是同一个,避免so重复加载。
  2. 打开so并得到so句柄,如果so句柄获取失败,就返回false。创建新的SharedLibrary,如果传入path对应的library 为空指针,就将新创建的SharedIibrary赋值给library, 并将library存储到libraries_中。
  3. 查找JNI_OnLoad 的函数指针,根据不同情况设置was_successful 的值,最终返回该was_successful。

讲到这里总结一下so修复主要有两个方案:

  1. 将so补丁插入到NativeLibraryElement数组的前部,让so补丁的路径先被返回和加载。
  2. 调用System的load方法来接管so的加载入口。

13.6 本章小结

2019/03/27 posted in  Android进阶解密

13 线性排序:如何根据年龄给100万用户数据排序?

三种时间复杂度是 O(n):桶排序、计数排序、基数排序。
因为这些排序算法的时间复杂度是线性的,所以我们把这类排序算法叫作线性排序

今天学习重点的是掌握这些排序算法的适用场景。

桶排序(Bucket sort)

首先,我们来看桶排序。桶排序,顾名思义,会用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

桶排序看起来很优秀,那它是不是可以替代我们之前讲的排序算法呢?

答案当然是否定的。桶排序对要排序数据的要求是非常苛刻的。

  • 首先,要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。
  • 其次,数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。

桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。

计数排序(Counting sort)

计数排序其实是桶排序的一种特殊情况。
当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。

计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。

基数排序(Radix sort)

基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。基数排序基于分别排序,分别收集,所以是稳定的。

基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了

2019/05/17 posted in  极客-数据结构与算法之美

13 线程安全与锁优化

2019/11/28 posted in  深入理解虚拟机

13 综合技术

2020/01/14 posted in  Android开发艺术探索

13部署Java应用程序

2019/03/28 posted in  Java核心技术

14 Hook技术

说到Hook技术得先提到逆向工程,逆向工程源于商业及军事领域中的硬件分析,其主要目的是在不能轻易获得必要的生产信息的情况下,直接从成品分析,推导出产品的设计原理。

逆向分析分为静态分析和动态分析,其中静态分析指的是一种在不执行程序的情况下对程序行为进行分析的技术;动态分析是指在程序运行时对程序进行调试的技术。

Hook技术就属于动态分析,它不仅在Android平台中被应用,早在Windows平台中就已经被应用了。

14.1 Hook技术概述

我们知道应用程序进程之间是彼此独立的,应用程序进程和系统进程之间也是如此,想要在应用程序进程更改系统进程的某些行为很难直接实现,有了Hook 技术,我们就可以在进程间进行行为更改,如图所示。

可以看到Hook可以将自己融入到它所要劫持的对象(对象B)所在的进程中,成为系统进程的一部分,这样我们就可以通过Hook来更改对象B的行为。被劫持的对象(对象B),称作Hook点,为了保证Hook的稳定性,Hook点一般选择容易找到并且不易变化的对象,静态变量和单例就符合这一条件。

14.2 Hook技术分类

Hook技术知识点比较多,因此Hook技术根据不同的角度会有很多种分类,这里介绍其中的三种分类。

  • 根据Hook的API语言划分,分为Hook Java和Hook Native。
    • Hook Java主要通过反射和代理来实现,应用于在SDK开发环境中修改Java代码。
    • HookNative则应用于在NDK开发环境和系统开发中修改Native代码。
  • 根据Hook的进程划分,分为应用程序进程Hook和全局Hook。
    • 应用程序进程Hook只能Hook当前所在的应用程序进程。
    • 应用程序进程是Zygote进程fock出来的,如果对Zygote进行Hook, 就可以实现Hook系统所有的应用程序进程,这就是全局Hook。
  • 根据Hook的实现方式划分,分为如下两种。
    • 通过反射和代理实现,只能Hook当前的应用程序进程。
    • 通过Hook框架来实现,比如Xposed, 可以实现全局Hook, 但是需要root。

Hook Native、全局Hook和通过Hook框架实现这些分类和插件化技术关联不大,本章主要需要学习的是Hook Java,想要更好地学习Hook Java,首先要了解代理模式。

14.3 代理模式

代理模式也叫委托模式,是结构型设计模式的一一种。在现实生活中我们用到类似代理模式的场景有很多,比如代购、代理上网、打官司等。

定义:为其他对象提供一种代理以控制对这个对象的访问称为代理模式。

在代理模式中有如下角色。

  • Subject: 抽象主题类,声明真实主题与代理的共同接口方法。
  • RealSubject: 真实主题类,定义了代理所表示的集体对象,客户端通过代理类间接调用真实主题类的方法。
  • Proxy: 代理类,持有对真实主题类的引用,在其所实现的接口方法中调用真实主题类中相应的接口方法执行。
  • Client: 客户端类。

14.3.1 代理模式简单实现

14.3.2 动态代理的简单实现

从编码的角度来说,代理模式分为静态代理和动态代理。

  • 静态代理,在代码运行前就已经存在了代理类的class编译文件
  • 动态代理则是在代码运行时通过反射来动态地生成代理类的对象,并确定到底来代理谁。Java 提供了动态的代理接口InvocationHandler,实现该接口需要重写invoke方法。

  1. 创建代理类,实现InvocationHandler接口
  2. 客户端类代码:

    public class Client {
        public static void main(String[] args) {
    //创建LiuWangShu
    IShop liuwangshu = new LiuWangShu() ;
    //创建动态代理
    DynamicPurchasing mDynamicPurchasing = new DynamicPurchasing(liuwangshu) ;
    //创建LiuWangShu的ClassLoader
    ClassLoader loader = liuwangshu.getClass().getClassLoader() ;
    //动态创建代理类
    IShop purchasing = (IShop)Proxy.newProxyInstance(loader, new Class[] {IShop.class}, mDynamicPurchasing) ;
    purchasing.buy() ;
    }
    }

14.4 Hook startActivity方法

我们知道Hook可以用来劫持对象,被劫持的对象叫作Hook点,用代理对象来替代Hook点,这样我们就可以在代理上实现自己想做的操作。这里以Hook常用的startActivity方法来举例,startActivity 方法分为两个,如下所示:

  • startActivity (intent) ;
  • getApplicationContext () . startActivity (intent) ;

第一个是Actvity的startActivity方法,第二个是Context的startActivity 方法。

14.4.1 Hook Activity的startActivity方法

14.4.2 Hook Context的startActivity方法

14.4.3 Hook startActivity总结

14.5 本章小结

2019/03/27 posted in  Android进阶解密

14 JNI和NDK编程

2020/01/14 posted in  Android开发艺术探索

14 | Java虚拟机是怎么实现synchronized的?

2019/12/14 posted in  极客-深入拆解Java虚拟机

14 | Lock和Condition(上):隐藏在并发包中的管程

  • 再造管程的理由
  • 如何保证可见性
  • 什么是可重入锁
  • 公平锁与非公平锁
  • 用锁的最佳实践

在并发编程领域,有两大核心问题:

  1. 一个是互斥,即同一时刻只允许一个线程访问共享资源;
  2. 另一个是同步,即线程之间如何通信、协作。

这两大问题,管程都是能够解决的。Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中Lock 用于解决互斥问题Condition 用于解决同步问题

再造管程的理由

我们前面在介绍05 | 一不小心就死锁了,怎么办?的时候,提出了一个破坏不可抢占条件方案,但是这个方案 synchronized 没有办法解决。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,也释放不了线程已经占有的资源。

如果我们重新设计一把互斥锁去解决这个问题,那该怎么设计呢?我觉得有三种方案。

  1. 能够响应中断。synchronized 的问题是,持有锁 A 后,如果尝试获取锁 B 失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说当我们给阻塞的线程发送中断信号的时候,能够唤醒它,那它就有机会释放曾经持有的锁 A。这样就破坏了不可抢占条件了。
  2. 支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
  3. 非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。

体现在 API 上,就是 Lock 接口的三个方法:

// 支持中断的API
void lockInterruptibly() 
  throws InterruptedException;
// 支持超时的API
boolean tryLock(long time, TimeUnit unit) 
  throws InterruptedException;
// 支持非阻塞获取锁的API
boolean tryLock();

如何保证可见性

那 Java SDK 里面 Lock 靠什么保证可见性呢?例如在下面的代码中,线程 T1 对 value 进行了 +=1 操作,那后续的线程 T2 能够看到 value 的正确结果吗?

class X {
  private final Lock rtl =
  new ReentrantLock();
  int value;
  public void addOne() {
    // 获取锁
    rtl.lock();  
    try {
      value+=1;
    } finally {
      // 保证锁能释放
      rtl.unlock();
    }
  }
}

Java SDK 里面锁,是利用了 volatile 相关的 Happens-Before 规则。Java SDK 里面的 ReentrantLock,内部持有一个 volatile 的成员变量 state,获取锁的时候,会读写 state 的值;解锁的时候,也会读写 state 的值。根据相关的 Happens-Before 规则:

  1. 顺序性规则:对于线程 T1,value+=1 Happens-Before 释放锁的操作 unlock();
  2. volatile 变量规则:由于 state = 1 会先读取 state,所以线程 T1 的 unlock() 操作 Happens-Before 线程 T2 的 lock() 操作;
  3. 传递性规则:线程 T1 的 value+=1 Happens-Before 线程 T2 的 lock() 操作。

所以说,后续线程 T2 能够看到 value 的正确结果。02 | Java内存模型:看Java如何解决可见性和有序性问题

什么是可重入锁

ReentrantLock,可重入锁,指的是线程可以重复获取同一把锁。如果锁是可重入的,那么线程 可以再次加锁成功;如果锁是不可重入的,那么线程会被阻塞。

可重入函数,指的是多个线程可以同时调用该函数,每个线程都能得到正确结果;同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。即线程安全。

公平锁与非公平锁

ReentrantLock 这个类有两个构造函数,一个是无参构造函数,一个是传入 fair 参数的构造函数。fair 参数代表的是锁的公平策略,如果传入 true 就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。

//无参构造函数:默认非公平锁
public ReentrantLock() {
    sync = new NonfairSync();
}
//根据公平策略参数创建锁
public ReentrantLock(boolean fair){
    sync = fair ? new FairSync() 
                : new NonfairSync();
}

在前面08 | 管程:并发编程的万能钥匙中,我们介绍过入口等待队列,锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒

用锁的最佳实践

《Java 并发编程:设计原则与模式》一书中,推荐的三个用锁的最佳实践,它们分别是:

  1. 永远只在更新对象的成员变量时加锁
  2. 永远只在访问可变的成员变量时加锁
  3. 永远不在调用其他对象的方法时加锁

总结

Java SDK 并发包里的 Lock 接口里面的每个方法,你可以感受到,都是经过深思熟虑的。除了支持类似 synchronized 隐式加锁的 lock() 方法外,还支持超时、非阻塞、可中断的方式获取锁,这三种方式为我们编写更加安全、健壮的并发程序提供了很大的便利。希望你以后在使用锁的时候,一定要仔细斟酌。

除了并发大师 Doug Lea 推荐的三个最佳实践外,你也可以参考一些诸如:减少锁的持有时间、减小锁的粒度等业界广为人知的规则,其实本质上它们都是相通的,不过是在该加锁的地方加锁而已。你可以自己体会,自己总结,最终总结出自己的一套最佳实践来。

课后思考

你已经知道 tryLock() 支持非阻塞方式获取锁,下面这段关于转账的程序就使用到了 tryLock(),你来看看,它是否存在死锁问题呢?

class Account {
  private int balance;
  private final Lock lock
          = new ReentrantLock();
  // 转账
  void transfer(Account tar, int amt){
    while (true) {
      if(this.lock.tryLock()) {
        try {
          if (tar.lock.tryLock()) {
            try {
              this.balance -= amt;
              tar.balance += amt;
            } finally {
              tar.lock.unlock();
            }
          }//if
        } finally {
          this.lock.unlock();
        }
      }//if
    }//while
  }//transfer
}
2019/12/05 posted in  极客-Java并发实战

14 并发

14.1 什么是线程

14.1.1 使用线程给其他任务提供机会

14.2 中断线程

14.3 线程状态

14.3.1 新创建线程

14.3.2 可运行线程

14.3.3 被阻塞线程和等待线程

14.3.4 被终止的线程

14.4 线程属性

14.4.1 线程优先级

14.4.2 守护线程

14.4.3 未捕获异常处理器

14.5 同步

14.5.1 竞争条件的一个例子

14.5.2 竞争条件详解

14.5.3 锁对象

14.5.4 条件对象

14.5.5 synchronized关键字

14.5.6 同步阻塞

14.5.7 监视器概念

14.5.8 Volatile域

14.5.9 final变量

14.5.10 原子性

14.5.11 死锁

14.5.12 线程局部变量

14.5.13 锁测试与超时

14.5.14 读/写锁

14.5.15 为什么弃用stop和suspend方法

14.6 阻塞队列

14.7 线程安全的集合

14.7.1 高效的映射、集和队列

14.7.2 映射条目的原子更新

14.7.3 对并发散列映射的批操作

14.7.4 并发集视图

14.7.5 写数组的拷贝

14.7.6 并行数组算法

14.7.7 较早的线程安全集合

14.8 Callable与Future

14.9 执行器

14.9.1 线程池

14.9.2 预定执行

14.9.3 控制任务组

14.9.4 Fork-Join框架

14.9.5 可完成Future

14.10 同步器

14.10.1 信号量

14.10.2 倒计时门栓

14.10.3 障栅

14.10.4 交换器

14.10.5 同步队列

14.11 线程与Swing

14.11.1 运行耗时的任务

14.11.2 使用Swing工作线程

14.11.3 单一线程规则

2019/03/28 posted in  Java核心技术

14 排序优化:如何实现一个通用的、高性能的排序函数?

如何选择合适的排序算法?

  1. 线性排序算法的时间复杂度比较低,适用场景比较特殊,所以不能选择线性排序算法。
  2. 为了兼顾任意规模数据的排序,一般都会首选时间复杂度是 O(nlogn) 的排序算法来实现排序函数。
  3. 归并排序并不是原地排序算法,空间复杂度是 O(n)。
  4. 所以快速排序比较适合来实现排序函数。

但是,快速排序在最坏情况下的时间复杂度是 O(n²),如何来解决这个“复杂度恶化”的问题呢?

如何优化快速排序?

为什么最坏情况下快速排序的时间复杂度是 O(n²) 呢?
如果数据原来就是有序的或者接近有序的,每次分区点都选择最后一个数据,时间复杂度就会退化为 O(n²)。** O(n²) 时间复杂度出现的主要原因还是因为我们分区点选的不够合理。**

如何来选择分区点呢?

最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。

1. 三数取中法

我们从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。但是,如果要排序的数组比较大,那“三数取中”可能就不够了,可能要“五数取中”或者“十数取中”。

2. 随机法

随机法就是每次从要排序的区间中,随机选择一个元素作为分区点。这种方法并不能保证每次分区点都选的比较好,但是从概率的角度来看,也不大可能会出现每次分区点都选的很差的情况,所以平均情况下,这样选的分区点是比较好的。

防止快速排序递归的堆栈溢出:

  1. 第一种是限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归。
  2. 第二种是通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制。

在小规模数据面前,O(n²) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长。
解释:在大 O 复杂度表示法中,我们会省略低阶、系数和常数,也就是说,O(nlogn) 在没有省略低阶、系数、常数之前可能是 O(knlogn + c),而且 k 和 c 有可能还是一个比较大的数。

2019/05/17 posted in  极客-数据结构与算法之美

14 迭代器模式

遍历聚合对象中的元素——迭代器模式

迭代器模式(Iterator Pattern)是 Java 和 .Net 编程环境中非常常用的设计模式。这种模式用于顺序访问集合对象的元素,不需要知道集合对象的底层表示。
迭代器模式属于行为型模式。

介绍

意图:提供一种方法顺序访问一个聚合对象中各个元素, 而又无须暴露该对象的内部表示。
主要解决:不同的方式来遍历整个整合对象。
何时使用:遍历一个聚合对象。
如何解决:把在元素之间游走的责任交给迭代器,而不是聚合对象。
关键代码:定义接口:hasNext, next。
应用实例:JAVA 中的 iterator。
优点: 1、它支持以不同的方式遍历一个聚合对象。 2、迭代器简化了聚合类。 3、在同一个聚合上可以有多个遍历。 4、在迭代器模式中,增加新的聚合类和迭代器类都很方便,无须修改原有代码。
缺点:由于迭代器模式将存储数据和遍历数据的职责分离,增加新的聚合类需要对应增加新的迭代器类,类的个数成对增加,这在一定程度上增加了系统的复杂性。
使用场景: 1、访问一个聚合对象的内容而无须暴露它的内部表示。 2、需要为聚合对象提供多种遍历方式。 3、为遍历不同的聚合结构提供一个统一的接口。
注意事项:迭代器模式就是分离了集合对象的遍历行为,抽象出一个迭代器类来负责,这样既可以做到不暴露集合的内部结构,又可让外部代码透明地访问集合内部的数据。

2019/11/01 posted in  菜鸟-设计模式

15 | Java语法糖与Java编译器

2019/12/14 posted in  极客-深入拆解Java虚拟机

15 | Lock和Condition(下):Dubbo如何用管程实现异步转同步?

  • 同步与异步
  • Dubbo 源码分析

Condition 实现了管程模型里面的条件变量。

Java 语言内置的管程里只有一个条件变量,而 Lock&Condition 实现的管程是支持多个条件变量的。

那如何利用两个条件变量快速实现阻塞队列呢?

一个阻塞队列,需要两个条件变量,一个是队列不空(空队列不允许出队),另一个是队列不满(队列已满不允许入队),这个例子我们前面在介绍管程的时候详细说过。

Lock 和 Condition 实现的管程,线程等待和通知需要调用 await()、signal()、signalAll(),它们的语义和 wait()、notify()、notifyAll() 是相同的。

public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 条件变量:队列不满  
  final Condition notFull =
    lock.newCondition();
  // 条件变量:队列不空  
  final Condition notEmpty =
    lock.newCondition();

  // 入队
  void enq(T x) {
    lock.lock();
    try {
      while (队列已满){
        // 等待队列不满
        notFull.await();
      }  
      // 省略入队操作...
      //入队后,通知可出队
      notEmpty.signal();
    }finally {
      lock.unlock();
    }
  }
  // 出队
  void deq(){
    lock.lock();
    try {
      while (队列已空){
        // 等待队列不空
        notEmpty.await();
      }  
      // 省略出队操作...
      //出队后,通知可入队
      notFull.signal();
    }finally {
      lock.unlock();
    }  
  }
}

同步与异步

同步与异步的区别?
调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步。

同步,是 Java 代码默认的处理方式。如果你想让你的程序支持异步,可以通过下面两种方式来实现:

  1. 调用方创建一个子线程,在子线程中执行方法调用,这种调用我们称为异步调用;
  2. 方法实现的时候,创建一个新的线程执行主要逻辑,主线程直接 return,这种方法我们一般称为异步方法。

Dubbo 源码分析

在 TCP 协议层面,发送完 RPC 请求后,线程是不会等待 RPC 的响应结果的。其实很简单,一定是有人帮你做了异步转同步的事情。

2019/12/05 posted in  极客-Java并发实战

15 二分查找上:如何用最省内存的方式实现快速查找功能?

二分思想

随机写一个 0 到 99 之间的数字,然后你来猜我写的是什么。假设我写的数字是 23,你可以按照下面的步骤来试一试。

二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。

时间复杂度就是 O(logn),它是对数时间复杂度

二分查找的递归与非递归实现

最简单的情况就是有序数组中不存在重复元素,我们在其中用二分查找值等于给定值的数据。

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;

  while (low <= high) {
    int mid = (low + high) / 2;
    if (a[mid] == value) {
      return mid;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }

  return -1;
}

容易出错的 3 个地方

  1. 循环退出条件
    • 注意是 low<=high,而不是 low<high。
  2. mid 的取值
    1. mid=(low+high)/2 这种写法是有问题的。因为如果 low 和 high 比较大的话,两者之和就有可能会溢出。改进的方法是将 mid 的计算方式写成 low+(high-low)/2。
    2. 如果要将性能优化到极致的话,可以将除以 2 操作转化成位运算 low+((high-low)>>1)。因为相比除法运算来说,计算机处理位运算要快得多。
  3. low 和 high 的更新
    • low=mid+1,high=mid-1。注意这里的 +1 和 -1,如果直接写成 low=mid 或者 high=mid,就可能会发生死循环。比如,当 high=3,low=3 时,如果 a[3] 不等于 value,就会导致一直循环不退出

递归实现:

// 二分查找的递归实现
public int bsearch(int[] a, int n, int val) {
  return bsearchInternally(a, 0, n - 1, val);
}

private int bsearchInternally(int[] a, int low, int high, int value) {
  if (low > high) return -1;

  int mid =  low + ((high - low) >> 1);
  if (a[mid] == value) {
    return mid;
  } else if (a[mid] < value) {
    return bsearchInternally(a, mid+1, high, value);
  } else {
    return bsearchInternally(a, low, mid-1, value);
  }
}

二分查找应用场景的局限性

  • 首先,二分查找依赖的是顺序表结构,简单点说就是数组。
    • 其他数据结构存储的,无法应用二分查找。
  • 其次,二分查找针对的是有序数据。
    • 如果有频繁的插入和删除操作,要想用二分查找,要么每次插入、删除操作之后保证数据仍然有序,要么在每次二分查找之前都先进行排序。维护有序的成本都是很高的。
  • 再次,数据量太小不适合二分查找。
  • 最后,数据量太大也不适合二分查找。
    • 二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。

解答开篇

如何在 1000 万个整数中快速查找某个整数?

我们的内存限制是 100MB,每个数据大小是 8 字节,最简单的办法就是将数据存储在数组中,内存占用差不多是 80MB,符合内存的限制。我们可以先对这 1000 万数据从小到大排序,然后再利用二分查找算法,就可以快速地查找想要的数据了。

注意,虽然大部分情况下,用二分查找可以解决的问题,用散列表、二叉树都可以解决。但是,不管是散列表还是二叉树,都会需要比较多的额外的内存空间。如果用散列表或者二叉树来存储这 1000 万的数据,用 100MB 的内存肯定是存不下的。而二分查找底层依赖的是数组,除了数据本身之外,不需要额外存储其他信息,是最省内存空间的存储方式,所以刚好能在限定的内存大小下解决这个问题。

内容小结

今天我们学习了一种针对有序数据的高效查找算法,二分查找,它的时间复杂度是 O(logn)。

二分查找的核心思想理解起来非常简单,有点类似分治思想。即每次都通过跟区间中的中间元素对比,将待查找的区间缩小为一半,直到找到要查找的元素,或者区间被缩小为 0。但是二分查找的代码实现比较容易写错。你需要着重掌握它的三个容易出错的地方:循环退出条件、mid 的取值,low 和 high 的更新。

二分查找虽然性能比较优秀,但应用场景也比较有限。底层必须依赖数组,并且还要求数据是有序的。对于较小规模的数据查找,我们直接使用顺序遍历就可以了,二分查找的优势并不明显。二分查找更适合处理静态数据,也就是没有频繁的数据插入、删除操作。

2019/05/17 posted in  极客-数据结构与算法之美

15 性能优化

本节介绍了一些有效的性能优化方法,主要内容包括布局优化、绘制优化、内存泄露优化、响应速度优化、ListView 优化、Bitmap 优化、线程优化以及一些性能优化建议,在介绍响应速度优化的同时还介绍了ANR日志的分析方法。

15.1.1 布局优化

布局优化的思想很简单,就是尽量减少布局文件的层级,布局中的层级少了,这就意味着Android绘制时的工作量少了,那么程序的性能自然就高了。

首先删除布局中无用的控件和层级,其次有选择地使用性能较低的ViewGroup。布局优化的另外一种手段是采用标签、 标签和ViewStub。

  • 标签主要用于布局重用;
  • 标签一般和配合使用,它可以降低减少布局的层级;
  • ViewStub则提供了按需加载的功能,当需要时才会将ViewStub中的布局加载到内存,这提高了程序的初始化效率。

15.1.2 绘制优化

绘制优化是指View的onDraw方法要避免执行大量的操作,这主要体现在两个方面。

  1. 首先,onDraw中不要创建新的局部对象,这是因为onDraw方法可能会被频繁调用,这样就会在一瞬间产生大量的临时对象,这不仅占用了过多的内存而且还会导致系统更加频繁gc,降低了程序的执行效率。
  2. 另外一方面,onDraw 方法中不要做耗时的任务,也不能执行成千上万次的循环操作,尽管每次循环都很轻量级,但是大量的循环仍然十分抢占CPU的时间片,这会造成View的绘制过程不流畅。按照Google官方给出的性能优化典范中的标准,View 的绘制帧率保证60fps是最佳的,这就要求每帧的绘制时间不超过16ms ( 16ms= 1000/ 60),虽然程序很难保证16ms这个时间,但是尽量降低onDraw方法的复杂度总是切实有效的。

15.1.3 内存泄露优化

内存泄露的优化分为两个方面,一方面是在开发过程中避免写出有内存泄露的代码,另一方 面是通过一些分析工具比如来找出潜在的内存泄露继而解决。

  • 场景1:静态变量导致的内存泄露
  • 场景2:单例模式导致的内存泄露
  • 场景3:属性动画导致的内存泄露

15.1.4 响应速度优化和ANR日志分析

响应速度优化的核心思想是避免在主线程中做耗时操作,但是有时候的确有很多耗时操作,怎么办呢?
可以将这些耗时操作放在线程中去执行,即采用异步的方式执行耗时操作。响应速度过慢更多地体现在Activity 的启动速度上面,如果在主线程中做太多事情,会导致Activity启动时出现黑屏现象,甚至出现ANR。
Android 规定,Activity 如果5秒钟之内无法响应屏幕触摸事件或者键盘输入事件就会出现ANR,而BroadcastReceiver 如果10秒钟之内还未执行完操作也会出现ANR。
在实际开发中,ANR是很难从代码上发现的,如果在开发过程中遇到了ANR,那么怎么定位问题呢?其实当一个进程发生ANR了以后,系统会在/data/anr目录下创建一个文件traces.txt,通过分析这个文件就能定位出ANR的原因。

15.1.5 ListView和Bitmap优化

ListView的优化(适用于GridView):

  1. 首先要采用ViewHolder并避免在getView中执行耗时操作;
  2. 其次要根据列表的滑动状态来控制任务的执行频率,比如当列表快速滑动时显然是不太适合开启大量的异步任务的;
  3. 最后可以尝试开启硬件加速来使Listview的滑动更加流畅。

Bitmap的优化,主要是通过BitmapFactory.Options来根据需要对图片进行采样,采样过程中主要用到了BitmapFactory.Options 的inSampleSize参数。

15.1.6 线程优化

线程优化的思想是采用线程池,避免程序中存在大量的Thread。线程池可以重用内部的线程,从而避免了线程的创建和销毁所带来的性能开销,同时线程池还能有效地控制线程池的最大并发数,避免大量的线程因互相抢占系统资源从而导致阻塞现象的发生。因此在实际开发中,我们要尽量采用线程池,而不是每次都要创建一个 Thread对象。

15.1.7 一些性能优化建议

  • 避免创建过多的对象;
  • 不要过多使用枚举,枚举占用的内存空间要比整型大;
  • 常量请使用static final来修饰;
  • 使用一 些Android 特有的数据结构,比如SparseArray和Pair等,它们都具有更好的性能;
  • 适当使用软引用和弱引用;
  • 采用内存缓存和磁盘缓存:
  • 尽量采用静态内部类,这样可以避免潜在的由于内部类而导致的内存泄露。

15.2 内存泄露分析之MAT工具

15.3 提高程序的可维护性

2019/03/06 posted in  Android开发艺术探索

15 插件化技术

插件化技术和热修复技术都属于动态加载技术。

15.1 动态加载技术

在Android传统开发中,一旦应用的代码被打包成APK并被上传到各个渠道市场,我们就不能修改应用的源码了,只能通过服务器来控制应用中预留的分支代码。但是很多时候我们无法提前预知需求和突然发生的情况,也就不能提前在应用代码中预留分支代码,这时就需要采用动态加载技术。

在应用程序运行时,动态加载一些程序中原本不存在的可执行文件并运行这些文件里的代码逻辑。可执行文件总的来说分为两种,一种是动态链接库so,另一种是dex相关文件(dex 以及包含dex的jar/apk文件)。

随着应用开发技术和业务的逐步发展,动态加载技术派生出两个技术,分别是热修复技术和插件化技术,其中热修复技术主要用来修复Bug, 插件化技术则主要用于解决应用越来越庞大以及功能模块的解耦。

15.2 插件化的产生

15.2.1 应用开发的痛点和瓶颈

  1. 业务复杂,模块耦合
  2. 应用间的接入
  3. 65536限制,内存占用大

15.2.2 插件化思想

15.2.3 插件化定义

插件化的客户端由宿主和插件两个部分组成。宿主就是指先被安装到手机中的APK,就是平常我们加载的普通APK。插件一般是指经过处理的APK.so和dex等文件,插件可以被宿主进行加载,有的插件也可以作为APK独立运行。

讲到这里就可以引出插件化的定义:将一个应用按照插件的方式进行改造的过程就叫作插件化。

在协作方面,插件可以由一个人或者一个小组来进行开发,这样各个插件之间,以及插件和宿主之间的耦合度会降低。应用间的接入和维护也变得便捷,每个应用团队只需要负责自己的那一部分就可以了。应用以及主dex的体积也会相应变小,间接地避免了65536限制。第一次加载到内存的只有淘宝主客户端,当使用到其他插件时才会加载相应插件到内存,这样就减少了内存的占用。

15.3 插件化框架对比

15.4 Activity插件化

四大组件的插件化是插件化技术的核心知识点,而Activity 插件化更是重中之重,Activity插件化主要有3种实现方式,分别是反射实现、接口实现和Hook技术实现。反射实现会对性能有所影响,主流的插件化框架没有采用此方式,关于接口实现可以阅读dynamic-load-apk的源码,目前Hook技术实现是主流,因此本章主要介绍Hook技术实现。

Hook技术实现主要有两种解决方案,一种是通过Hook IActivityManager来实现,另一种是Hook Instrumentation实现。在讲到这两个解决方案前,我们需要从整体上了解Activity的启动流程。

15.4.1 Activity的启动过程回顾

首先Launcher进程向AMS请求创建根Activity, AMS会判断根Activity所需的应用程序进程是否存在并启动,如果不存在就会请求Zygote进程创建应用程序进程。应用程序进程启动后, AMS会请求应用程序进程创建并启动根Activity。

普通Activity和根Activity的启动过程大同小异,不涉及应用程序进程的创建,与Launcher也没关系:

在应用程序进程中的Activity 向AMS请求创建普通Activity (步骤1),AMS会对这个Activity的生命周期和栈进行管理,校验Activity等。如果Activity 满足AMS的校验,AMS就会请求应用程序进程中的ActivityThread去创建并启动普通Activity (步骤2)。

15.4.2 Hook IActivityManager方案实现

AMS存在于SystemServer 进程中,我们无法直接修改,只能在应用程序进程中做文章。可以采用预先占坑的方式来解决没有在AndroidManifest.xml 中显式声明的问题,具体做法就是先使用一个在AndroidManifest.xml中注册的Activity来进行占坑,用来通过AMS的校验。接着用插件Activity替换占坑的Activity。

  1. 注册Activity进行占坑
  2. 使用占坑Activity通过AMS验证
  3. 还原插件Activity
  4. 插件Activity的生命周期

15.4.3 Hook Instrumentation方案实现

Hook Instrumentation 实现同样也需要用到占坑Activity,与Hook IActivityManager实现不同的是,用占坑Activity替换插件Activity以及还原插件Activity 的地方不同。

15.4.4 总结

15.5 Service插件化

15.5.1 插件化方面Service与Activity的不同

我们需要了解在插件化方面Activity和Service有何不同:

  • Activity是基于栈管理的,一个栈中的Activity的数量不会太多,因此插件化框架处理的插件Activity 数量是有限的,可以声明有限的占坑Activity 来实现。除去硬件和系统限制,插件化框架处理的插件Service的数量可以是近乎无限的,无法用有限的占坑Service来实现。
  • 在Standard模式下多次启动同一个占坑Activity 可以创建多个Activity 实例,但是多次启动占坑Service并不会创建多个Service实例。
  • 用户和界面的交互会影响到Activity 的生命周期,因此插件Activity 的生命周期需要交由系统来管理,Hook IActivityManager方案中还原插件Activity 就是为了这一点。Service的生命周期不受用户影响,可以由开发者管理生命周期,没有必要还原插件。

15.5.2 代理分发实现

Activity插件化的重点在于要保证它的生命周期,而Service插件化的重点是保证它的优先级,这就需要用一个真正的Service 来实现,而不是像占坑Activity那样起一个占坑的作用。当启动插件Service时,就会先启动代理Service,当这个代理Service 运行起来之后,在它的onStartCommand等方法里面进行分发,执行插件TargetService 的onCreate等方法,这一方案就叫作代理分发。

  1. 启动代理Service
  2. 代理分发

15.6 ContentProvider插件化

15.6.1 ContentProvider的启动过程回顾

15.6.2 VirtualApk的实现

  1. VirtualApk 初始化
  2. 启动代理ContentProvider
  3. 代理分发

15.7 BroadcastReceiver的插件化

15.7.1 广播插件化思路

IntentFilter的,讲到这里我们得到了一个新思路,那就是将静态注册的BroadcastReceiver全部转换为动态注册来处理,虽然静态和动态的BroadcastReceiver 的生命周期不同,但是为了实现插件化,这个缺点显然不是关键问题。

15.7.2 VirtualApk的实现

15.8 资源的插件化

15.8.1 系统资源加载

15.8.2 VirtualApk实现

资源的插件化方案主要有两种:一种是合并资源方案,将插件的资源全部添加到宿主的Resources中,这种方案插件可以访问宿主的资源。另一 种是构建插件资源方案,每个插件都构造出独立的Resources,这种方案插件不可以访问宿主资源。VirtualApk 采用了以上两种方案。

15.9 so的插件化

so热修复主要有两种方案:

  • 将so补丁插入到NativeLibraryElement数组的前部,让so补丁的路径先被返回和加载。
  • 调用System的load方法来接管so的加载入口。

so的插件化的方案和so热修复第一种方案类似,简单来说就是将so插件插入到NativeLibraryElement数组中,并且将存储so插件的文件添加到nativeLibraryDirectories 集合中就可以了。

15.10 本章小结

2019/03/27 posted in  Android进阶解密

15 模板方法模式

在模板模式(Template Pattern)中,一个抽象类公开定义了执行它的方法的方式/模板。它的子类可以按需要重写方法实现,但调用将以抽象类中定义的方式进行。这种类型的设计模式属于行为型模式。

介绍

意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
主要解决:一些方法通用,却在每一个子类都重新写了这一方法。
何时使用:有一些通用的方法。
如何解决:将这些通用算法抽象出来。
关键代码:在抽象类实现,其他步骤在子类实现。
应用实例: 1、在造房子的时候,地基、走线、水管都一样,只有在建筑的后期才有加壁橱加栅栏等差异。 2、西游记里面菩萨定好的 81 难,这就是一个顶层的逻辑骨架。 3、spring 中对 Hibernate 的支持,将一些已经定好的方法封装起来,比如开启事务、获取 Session、关闭 Session 等,程序员不重复写那些已经规范好的代码,直接丢一个实体就可以保存。
优点: 1、封装不变部分,扩展可变部分。 2、提取公共代码,便于维护。 3、行为由父类控制,子类实现。
缺点:每一个不同的实现都需要一个子类来实现,导致类的个数增加,使得系统更加庞大。
使用场景: 1、有多个子类共有的方法,且逻辑相同。 2、重要的、复杂的方法,可以考虑作为模板方法。
注意事项:为防止恶意操作,一般模板方法都加上 final 关键词。

2019/11/01 posted in  菜鸟-设计模式

16 | Semaphore:如何快速实现一个限流器?

2019/12/05 posted in  极客-Java并发实战

16 | 即时编译(上)

2019/12/14 posted in  极客-深入拆解Java虚拟机

16 二分查找下:如何快速定位IP对应的省份地址?

“十个二分九个错”。二分查找虽然原理极其简单,但是想要写出没有 Bug 的二分查找并不容易。

变体一:查找第一个值等于给定值的元素

比如下面这样一个有序数组,其中,a[5],a[6],a[7] 的值都等于 8,是重复的数据。我们希望查找第一个等于 8 的数据,也就是下标是 5 的元素。如果我们用上一节课讲的二分查找的代码实现,首先拿 8 与区间的中间值 a[4] 比较,8 比 6 大,于是在下标 5 到 9 之间继续查找。下标 5 和 9 的中间位置是下标 7,a[7] 正好等于 8,所以代码就返回了。

代码实现:

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      if ((mid == 0) || (a[mid - 1] != value)) return mid;
      else high = mid - 1;
    }
  }
  return -1;
}

很多人都觉得变形的二分查找很难写,主要原因是太追求第一种那样完美、简洁的写法。

变体二:查找最后一个值等于给定值的元素

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] != value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

变体三:查找第一个大于等于给定值的元素

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] >= value) {
      if ((mid == 0) || (a[mid - 1] < value)) return mid;
      else high = mid - 1;
    } else {
      low = mid + 1;
    }
  }
  return -1;
}

变体四:查找最后一个小于等于给定值的元素

public int bsearch7(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] > value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

解答开篇

如何快速定位出一个 IP 地址的归属地?

  1. 排序。IP 地址可以转化为 32 位的整型数。将起始地址,按照对应的整型值的大小关系,从小到大进行排序。
  2. 二分查找,在有序数组中,查找最后一个小于等于某个给定值的元素。

内容小结

上一节我说过,凡是用二分查找能解决的,绝大部分我们更倾向于用散列表或者二叉查找树。即便是二分查找在内存使用上更节省,但是毕竟内存如此紧缺的情况并不多。那二分查找真的没什么用处了吗?

实际上,上一节讲的求“值等于给定值”的二分查找确实不怎么会被用到,二分查找更适合用在“近似”查找问题,在这类问题上,二分查找的优势更加明显。比如今天讲的这几种变体问题,用其他数据结构,比如散列表、二叉树,就比较难实现了。

变体的二分查找算法写起来非常烧脑,很容易因为细节处理不好而产生 Bug,这些容易出错的细节有:终止条件、区间上下界更新方法、返回值选择。所以今天的内容你最好能用自己实现一遍,对锻炼编码能力、逻辑思维、写出 Bug free 代码,会很有帮助。

2019/05/17 posted in  极客-数据结构与算法之美

16 绘制优化

16.1 绘制性能分析

Android应用需要将自己的界面展示给用户,用户会和界面进行交互,界面的流畅度至关重要,这一节我们就来学习绘制性能分析,首先讲解绘制原理,接着介绍绘制性能分析的工具: Profile GPU Rendering、Systrace 和Traceview。

16.1.1 绘制原理

View的绘制流程有3个步骤,分别是measure、layout 和draw,它们主要运行在系统的应用框架层,而真正将数据渲染到屏幕上的则是系统Native层的SurfaceFlinger 服务来完成的。

绘制过程主要由CPU来进行Measure、Layout.、Record、Execute的数据计算工作, GPU负责栅格化、渲染。CPU和GPU是通过图形驱动层来进行连接的,图形驱动层维护了一个队列,CPU将displaylist添加到该队列中,这样GPU就可以从这个队列中取出数据进行绘制。

说到绘制性能就需要提到帧数这个概念。帧数就是在1秒时间里传输的图片的量,也可以理解为图形处理器每秒钟能够刷新几次,通常用FPS (Frames Per Second)表示。每一帧其实就是静止的图像,通过快速连续地显示帧便形成了运动的假象。如果画面在60fps则不会感觉到卡顿,如果低于60fps,比如50fps则会感觉到卡顿。要想画面保持在60fps,需要屏幕在1秒内刷新60次,也就是每16.6667ms 刷新一次(绘制时长在16ms以内)。

Android系统每隔16ms 发出VSYNC信号,触发对UI进行渲染,如果每次渲染都成功,这样就能够达到流畅的画面所需要的60fps, 那什么是VSYNC呢? VSYNC是VerticalSynchronization (垂直同步)的缩写,是一种定时中断,一旦收到VSYNC信号,CPU就开始处理各帧数据。如果某个操作要花费24ms,这样系统在得到VSYNC信号时无法进行正常的渲染,会发生丢帧,用户会在32ms中看到同一帧的画面。

产生卡顿原因有很多,主要有以下几点:

  • 布局Layout过于复杂,无法在16ms内完成渲染。
  • 同一时间动画执行的次数过多,导致CPU或GPU负载过重。
  • View过度绘制,导致某些像素在同一帧时间内被绘制多次。
  • 在UI线程中做了稍微耗时的操作。
  • GC回收时暂停时间过长或者频繁的GC产生大量的暂停时间。

16.1.2 Profile GPU Rendering

16.1.3 Systrace

16.1.4 Traceview

16.2 布局优化

16.2.1 布局优化工具

16.2.2 布局优化方法

16.2.3 避免GPU过度绘制

16.3 本章小结

2019/03/27 posted in  Android进阶解密

16 访问者模式

操作复杂对象结构——访问者模式

在访问者模式(Visitor Pattern)中,我们使用了一个访问者类,它改变了元素类的执行算法。通过这种方式,元素的执行算法可以随着访问者改变而改变。这种类型的设计模式属于行为型模式。根据模式,元素对象已接受访问者对象,这样访问者对象就可以处理元素对象上的操作。

介绍

意图:主要将数据结构与数据操作分离。
主要解决:稳定的数据结构和易变的操作耦合问题。
何时使用:需要对一个对象结构中的对象进行很多不同的并且不相关的操作,而需要避免让这些操作"污染"这些对象的类,使用访问者模式将这些封装到类中。
如何解决:在被访问的类里面加一个对外提供接待访问者的接口。
关键代码:在数据基础类里面有一个方法接受访问者,将自身引用传入访问者。
应用实例:您在朋友家做客,您是访问者,朋友接受您的访问,您通过朋友的描述,然后对朋友的描述做出一个判断,这就是访问者模式。
优点: 1、符合单一职责原则。 2、优秀的扩展性。 3、灵活性。
缺点: 1、具体元素对访问者公布细节,违反了迪米特原则。 2、具体元素变更比较困难。 3、违反了依赖倒置原则,依赖了具体类,没有依赖抽象。
使用场景: 1、对象结构中对象对应的类很少改变,但经常需要在此对象结构上定义新的操作。 2、需要对一个对象结构中的对象进行很多不同的并且不相关的操作,而需要避免让这些操作"污染"这些对象的类,也不希望在增加新操作时修改这些类。
注意事项:访问者可以对功能进行统一,可以做报表、UI、拦截器与过滤器。

2019/11/01 posted in  菜鸟-设计模式

17 | ReadWriteLock:如何快速实现一个完备的缓存?

2019/12/05 posted in  极客-Java并发实战

17 | 即时编译(下)

2019/12/14 posted in  极客-深入拆解Java虚拟机

17 中介者模式

协调多个对象之间的交互——中介者模式

中介者模式(Mediator Pattern)是用来降低多个对象和类之间的通信复杂性。这种模式提供了一个中介类,该类通常处理不同类之间的通信,并支持松耦合,使代码易于维护。中介者模式属于行为型模式。

介绍

意图:用一个中介对象来封装一系列的对象交互,中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。
主要解决:对象与对象之间存在大量的关联关系,这样势必会导致系统的结构变得很复杂,同时若一个对象发生改变,我们也需要跟踪与之相关联的对象,同时做出相应的处理。
何时使用:多个类相互耦合,形成了网状结构。
如何解决:将上述网状结构分离为星型结构。
关键代码:对象 Colleague 之间的通信封装到一个类中单独处理。
应用实例: 1、中国加入 WTO 之前是各个国家相互贸易,结构复杂,现在是各个国家通过 WTO 来互相贸易。 2、机场调度系统。 3、MVC 框架,其中C(控制器)就是 M(模型)和 V(视图)的中介者。
优点: 1、降低了类的复杂度,将一对多转化成了一对一。 2、各个类之间的解耦。 3、符合迪米特原则。
缺点:中介者会庞大,变得复杂难以维护。
使用场景: 1、系统中对象之间存在比较复杂的引用关系,导致它们之间的依赖关系结构混乱而且难以复用该对象。 2、想通过一个中间类来封装多个类中的行为,而又不想生成太多的子类。
注意事项:不应当在职责混乱的时候使用。

2019/11/01 posted in  菜鸟-设计模式

17 内存优化

17.1 避免可控的内存泄漏

17.1.1 什么是内存泄漏

每个应用程序都需要内存来完成工作,为了确保Android系统的每个应用都有足够的内存,Android系统需要有效地管理内存分配。当内存不足时,Android运行时就会触发GC,GC采用的垃圾标记算法为根搜索算法。

内存泄漏就是指没有用的对象到GC Roots 是可达的(对象被引用),导致GC无法回收该对象。

17.1.2 内存泄漏的场景

  1. 非静态内部类的静态实例
    1. 非静态内部类会持有外部类实例的引用,如果非静态内部类的实例是静态的,就会间接地长期维持着外部类的引用,阻止被系统回收。
  2. 多线程相关的匿名内部类/非静态内部类
  3. Handler内存泄漏
  4. 未正确使用Context
  5. 静态View
  6. WebView
  7. 资源对象未关闭
  8. 集合中对象没清理
  9. Bitmap对象
  10. 监听器未关闭

17.2 Memory Monitor

17.2.1 使用Memory Monitor

17.2.2 大内存申请与GC

17.2.3 内存抖动

内存抖动一般指在很短的时间内发生了多次内存分配和释放,严重的内存抖动还会导致应用程序卡顿。内存抖动出现的原因主要是短时间频繁地创建对象(可能在循环中创建对象),内存为了应对这种情况,也会频繁地进行GC。非并行GC在进行时,其他线程都会被挂起,等待GC操作完成后恢复工作。如果是频繁的GC就会产生大量的暂停时间,这会导致界面绘制时间减少,从而使得多次绘制一帧的时长超过了16ms, 产生的现象就是界面卡顿。综合起来就产生了内存抖动,产生了锯齿状的
抖动图。

17.3 Allocation Tracker

17.3.1 使用Allocation Tracker

17.3.2 alloc文件分析

17.4 Heap Dump

17.4.1 使用Heap Dump

17.4.2 检测内存泄漏

17.5 内存分析工具MAT

17.5.1 生成hprof文件

17.5.2 MAT分析hprof文件

17.6 LeakCanary

17.6.1 使用LeakCanary

17.6.2 LeakCanary应用举例

17.7 本章小结

2019/03/27 posted in  Android进阶解密

17 跳表:为什么Redis一定要用跳表来实现有序集合?

我们只需要对链表稍加改造,就可以支持类似“二分”的查找算法。我们把改造之后的数据结构叫作跳表(Skip list)。

跳表是一种各方面性能都比较优秀的动态数据结构,可以支持快速的插入、删除、查找操作,写起来也不复杂,甚至可以替代红黑树(Red-black tree)。

如何理解“跳表”?

对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)。

如果像图中那样,对链表建立一级“索引”,查找起来是不是就会更快一些呢?每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引或索引层。

原来如果要查找 16,需要遍历 10 个结点,现在只需要遍历 7 个结点。

加来一层索引之后,查找一个结点需要遍历的结点个数减少了,也就是说查找效率提高了。

例子2:

原来没有索引的时候,查找 62 需要遍历 62 个结点,现在只需要遍历 11 个结点。

这种链表加多级索引的结构,就是跳表。

用跳表查询到底有多快?

跳表中查询任意数据的时间复杂度就是 O(logn)。这个查找的时间复杂度跟二分查找是一样的。

跳表是不是很浪费内存?

跳表的空间复杂度是 O(n)。

在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。

高效的动态插入和删除

跳表这个动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是 O(logn)。

跳表索引动态更新

当我们不停地往跳表中插入数据时,如果我们不更新索引,就有可能出现某 2 个索引结点之间数据非常多的情况。极端情况下,跳表还会退化成单链表。

作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡,也就是说,如果链表中结点多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降。

跳表是通过随机函数来维护前面提到的“平衡性”。

我们通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。

解答开篇

为什么 Redis 要用跳表来实现有序集合,而不是红黑树?

  1. Redis 中的有序集合是通过跳表来实现的
  2. Redis 中的有序集合支持的核心操作主要有下面这几个:插入一个数据;删除一个数据;查找一个数据;按照区间查找数据(比如查找值在 [100, 356] 之间的数据);迭代输出有序序列。
  3. 按照区间来查找数据这个操作,红黑树的效率没有跳表高。对于按照区间查找数据这个操作,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。
  4. 其他原因,比如,跳表更容易代码实现。跳表更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗。

内容小结

今天我们讲了跳表这种数据结构。跳表使用空间换时间的设计思路,通过构建多级索引来提高查询的效率,实现了基于链表的“二分查找”。跳表是一种动态数据结构,支持快速的插入、删除、查找操作,时间复杂度都是 O(logn)。

跳表的空间复杂度是 O(n)。不过,跳表的实现非常灵活,可以通过改变索引构建策略,有效平衡执行效率和内存消耗。虽然跳表的代码实现并不简单,但是作为一种动态数据结构,比起红黑树来说,实现要简单多了。所以很多时候,我们为了代码的简单、易读,比起红黑树,我们更倾向用跳表。

2019/05/17 posted in  极客-数据结构与算法之美

18 | StampedLock:有没有比读写锁更快的锁?

2019/12/05 posted in  极客-Java并发实战

18 | 即时编译器的中间表达形式

2019/12/14 posted in  极客-深入拆解Java虚拟机

18 代理模式

在代理模式(Proxy Pattern)中,一个类代表另一个类的功能。这种类型的设计模式属于结构型模式。
在代理模式中,我们创建具有现有对象的对象,以便向外界提供功能接口。

介绍

意图:为其他对象提供一种代理以控制对这个对象的访问。
主要解决:在直接访问对象时带来的问题,比如说:要访问的对象在远程的机器上。在面向对象系统中,有些对象由于某些原因(比如对象创建开销很大,或者某些操作需要安全控制,或者需要进程外的访问),直接访问会给使用者或者系统结构带来很多麻烦,我们可以在访问此对象时加上一个对此对象的访问层。
何时使用:想在访问一个类时做一些控制。
如何解决:增加中间层。
关键代码:实现与被代理类组合。
应用实例: 1、Windows 里面的快捷方式。 2、猪八戒去找高翠兰结果是孙悟空变的,可以这样理解:把高翠兰的外貌抽象出来,高翠兰本人和孙悟空都实现了这个接口,猪八戒访问高翠兰的时候看不出来这个是孙悟空,所以说孙悟空是高翠兰代理类。 3、买火车票不一定在火车站买,也可以去代售点。 4、一张支票或银行存单是账户中资金的代理。支票在市场交易中用来代替现金,并提供对签发人账号上资金的控制。 5、spring aop。
优点: 1、职责清晰。 2、高扩展性。 3、智能化。
缺点: 1、由于在客户端和真实主题之间增加了代理对象,因此有些类型的代理模式可能会造成请求的处理速度变慢。 2、实现代理模式需要额外的工作,有些代理模式的实现非常复杂。
使用场景:按职责来划分,通常有以下使用场景: 1、远程代理。 2、虚拟代理。 3、Copy-on-Write 代理。 4、保护(Protect or Access)代理。 5、Cache代理。 6、防火墙(Firewall)代理。 7、同步化(Synchronization)代理。 8、智能引用(Smart Reference)代理。
注意事项: 1、和适配器模式的区别:适配器模式主要改变所考虑对象的接口,而代理模式不能改变所代理类的接口。 2、和装饰器模式的区别:装饰器模式为了增强功能,而代理模式是为了加以控制。

2019/11/01 posted in  菜鸟-设计模式

18散列表上:Word文档中的单词拼写检查功能是如何实现的?

散列思想

散列表的英文叫“HashTable”,我们平时也叫它“哈希表”或者“Hash表”。
散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。

参赛选手的编号我们叫作(key)或者关键字。我们用它来标识一个选手。我们把参赛编号转化为数组下标的映射方法就叫作散列函数(或“Hash函数”“哈希函数”),而散列函数计算得到的值就叫作散列值(或“Hash值”“哈希值”)。

散列表用的就是数组支持按照下标随机访问的时候,时间复杂度是O(1)的特性。我们通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当我们按照键值查询元素时,我们用同样的散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。

散列函数

散列函数,顾名思义,它是一个函数。我们可以把它定义成hash(key),其中key表示元素的键值,hash(key)的值表示经过散列函数计算得到的散列值。

该如何构造散列函数呢?我总结了三点散列函数设计的基本要求:

  1. 散列函数计算得到的散列值是一个非负整数;
  2. 如果key1=key2,那hash(key1)==hash(key2);
  3. 如果key1≠key2,那hash(key1)≠hash(key2)。

第三点说明:这个要求看起来合情合理,但是在真实的情况下,要想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。即便像业界著名的MD5SHACRC等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。

所以我们几乎无法找到一个完美的无冲突的散列函数,即便能找到,付出的时间成本、计算成本也是很大的,所以针对散列冲突问题,我们需要通过其他途径来解决。

散列冲突

如何解决散列冲突问题呢?我们常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)。

1. 开放寻址法

开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。

那如何重新探测新的位置呢?我先讲一个比较简单的探测方法,线性探测(Linear Probing)。
当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置;如果遍历到尾部都没有找到空闲的位置,就从表头开始找,直到找到为止。

对于使用线性探测法解决冲突的散列表,删除操作稍微有些特别。
在查找的时候,一旦我们通过线性探测方法,找到一个空闲位置,我们就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。本来存在的数据,会被认定为不存在。这个问题如何解决呢?
我们可以将删除的元素,特殊标记为 deleted。当线性探测查找的时候,遇到标记为 deleted 的空间,并不是停下来,而是继续往下探测。

线性探测法的问题:当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。

对于开放寻址冲突解决方法,除了线性探测方法之外,还有另外两种比较经典的探测方法,二次探测(Quadratic probing)和双重散列(Double hashing)。

所谓二次探测,跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0,hash(key)+12,hash(key)+22……

所谓双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少。

装载因子的计算公式是:

散列表的装载因子=填入表中的元素个数/散列表的长度

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

2. 链表法

链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。我们来看这个图,在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。

当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。那查找或删除操作的时间复杂度是多少呢?

实际上,这两个操作的时间复杂度跟链表的长度 k 成正比,也就是 O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。

解答开篇

Word 文档中单词拼写检查功能是如何实现的?

常用的英文单词有 20 万个左右,假设单词的平均长度是 10 个字母,平均一个单词占用 10 个字节的内存空间,那 20 万英文单词大约占 2MB 的存储空间,就算放大 10 倍也就是 20MB。对于现在的计算机来说,这个大小完全可以放在内存里面。所以我们可以用散列表来存储整个英文单词词典。

当用户输入某个英文单词时,我们拿用户输入的单词去散列表中查找。如果查到,则说明拼写正确;如果没有查到,则说明拼写可能有误,给予提示。借助散列表这种数据结构,我们就可以轻松实现快速判断是否存在拼写错误。

内容小结

今天我讲了一些比较基础、比较偏理论的散列表知识,包括散列表的由来、散列函数、散列冲突的解决方法。

散列表来源于数组,它借助散列函数对数组这种数据结构进行扩展,利用的是数组支持按照下标随机访问元素的特性。散列表两个核心问题是散列函数设计散列冲突解决。散列冲突有两种常用的解决方法,开放寻址法和链表法。散列函数设计的好坏决定了散列冲突的概率,也就决定散列表的性能。

2019/05/17 posted in  极客-数据结构与算法之美

19 | CountDownLatch和CyclicBarrier:如何让多线程步调一致?

2019/12/05 posted in  极客-Java并发实战

19 | Java字节码(基础篇)

2019/12/14 posted in  极客-深入拆解Java虚拟机

19 散列表中:如何打造一个工业级水平的散列表?

如何设计散列函数?

首先,散列函数的设计不能太复杂。过于复杂的散列函数,势必会消耗很多计算时间,也就间接的影响到散列表的性能。其次,散列函数生成的值要尽可能随机并且均匀分布,这样才能避免或者最小化散列冲突。

实际工作中,我们还需要综合考虑各种因素。这些因素有关键字的长度、特点、分布、还有散列表的大小等。散列函数各式各样,我举几个常用的、简单的散列函数的设计方法,让你有个直观的感受。

第一个例子就是我们上一节的学生运动会的例子,我们通过分析参赛编号的特征,把编号中的后两位作为散列值。我们还可以用类似的散列函数处理手机号码,因为手机号码前几位重复的可能性很大,但是后面几位就比较随机,我们可以取手机号的后四位作为散列值。这种散列函数的设计方法,我们一般叫作“数据分析法”。
第二个例子就是上一节的开篇思考题,如何实现 Word 拼写检查功能。这里面的散列函数,我们就可以这样设计:将单词中每个字母的ASCll 码值“进位”相加,然后再跟散列表的大小求余、取模,作为散列值。
实际上,散列函数的设计方法还有很多,比如直接寻址法、平方取中法、折叠法、随机数法等。

装载因子过大了怎么办?

针对散列表,当装载因子过大时,我们也可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。假设每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是 0.8,那经过扩容之后,新散列表的装载因子就下降为原来的一半,变成了 0.4。

如何避免低效地扩容?

我举一个极端的例子,如果散列表当前大小为 1GB,要想扩容为原来的两倍大小,那就需要对 1GB 的数据重新计算哈希值,并且从原来的散列表搬移到新的散列表,听起来就很耗时,是不是?

为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。

当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移,插入操作就都变得很快了。

这期间的查询操作怎么来做呢?对于查询操作,为了兼容了新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。

如何选择冲突解决方法?

开放寻址法和链表法有什么优势和劣势,又各自适用哪些场景?

  • 当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。
  • 基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。

工业级散列表举例分析

举例:Java 中的 HashMap 这样一个工业级的散列表,来具体看下,这些技术是怎么应用的。

1.初始大小HashMap
默认的初始大小是 16,当然这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高 HashMap 的性能。

2.装载因子和动态扩容
最大装载因子默认是 0.75,当 HashMap 中元素个数超过 0.75*capacity(capacity 表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。

3.散列冲突解决方法
HashMap 底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。
于是,在 JDK1.8 版本中,为了对 HashMap 做进一步优化,我们引入了红黑树。而当链表长度太长(默认超过 8)时,链表就转换为红黑树。我们可以利用红黑树快速增删改查的特点,提高 HashMap 的性能。当红黑树结点个数少于 8 个的时候,又会将红黑树转化为链表。

4.散列函数
散列函数的设计并不复杂,追求的是简单高效、分布均匀。

解答开篇

如何设计的一个工业级的散列函数?

  1. 何为一个工业级的散列表?工业级的散列表应该具有哪些特性?
    • 支持快速的查询、插入、删除操作;
    • 内存占用合理,不能浪费过多的内存空间;
    • 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况。
  2. 如何实现这样一个散列表呢?
    • 设计一个合适的散列函数;
    • 定义装载因子阈值,并且设计动态扩容策略;
    • 选择合适的散列冲突解决方法。

内容小结

上一节的内容比较偏理论,今天的内容侧重实战。我主要讲了如何设计一个工业级的散列表,以及如何应对各种异常情况,防止在极端情况下,散列表的性能退化过于严重。我分了三部分来讲解这些内容,分别是:如何设计散列函数,如何根据装载因子动态扩容,以及如何选择散列冲突解决方法。

关于散列函数的设计,我们要尽可能让散列后的值随机且均匀分布,这样会尽可能地减少散列冲突,即便冲突之后,分配到每个槽内的数据也比较均匀。除此之外,散列函数的设计也不能太复杂,太复杂就会太耗时间,也会影响散列表的性能。

关于散列冲突解决方法的选择,我对比了开放寻址法和链表法两种方法的优劣和适应的场景。大部分情况下,链表法更加普适。而且,我们还可以通过将链表法中的链表改造成其他动态查找数据结构,比如红黑树,来避免散列表时间复杂度退化成 O(n),抵御散列碰撞攻击。但是,对于小规模数据、装载因子不高的散列表,比较适合用开放寻址法。

对于动态散列表来说,不管我们如何设计散列函数,选择什么样的散列冲突解决方法。随着数据的不断增加,散列表总会出现装载因子过高的情况。这个时候,我们就需要启动动态扩容。

2019/05/17 posted in  极客-数据结构与算法之美

19 组合模式

树形结构的处理——组合模式

组合模式(Composite Pattern),又叫部分整体模式,是用于把一组相似的对象当作一个单一的对象。组合模式依据树形结构来组合对象,用来表示部分以及整体层次。这种类型的设计模式属于结构型模式,它创建了对象组的树形结构。
这种模式创建了一个包含自己对象组的类。该类提供了修改相同对象组的方式。
我们通过下面的实例来演示组合模式的用法。实例演示了一个组织中员工的层次结构。

介绍

意图:将对象组合成树形结构以表示"部分-整体"的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。
主要解决:它在我们树型结构的问题中,模糊了简单元素和复杂元素的概念,客户程序可以像处理简单元素一样来处理复杂元素,从而使得客户程序与复杂元素的内部结构解耦。
何时使用: 1、您想表示对象的部分-整体层次结构(树形结构)。 2、您希望用户忽略组合对象与单个对象的不同,用户将统一地使用组合结构中的所有对象。
如何解决:树枝和叶子实现统一接口,树枝内部组合该接口。
关键代码:树枝内部组合该接口,并且含有内部属性 List,里面放 Component。
应用实例: 1、算术表达式包括操作数、操作符和另一个操作数,其中,另一个操作符也可以是操作数、操作符和另一个操作数。 2、在 JAVA AWT 和 SWING 中,对于 Button 和 Checkbox 是树叶,Container 是树枝。
优点: 1、高层模块调用简单。 2、节点自由增加。
缺点:在使用组合模式时,其叶子和树枝的声明都是实现类,而不是接口,违反了依赖倒置原则。
使用场景:部分、整体场景,如树形菜单,文件、文件夹的管理。
注意事项:定义时为具体类。

2019/11/01 posted in  菜鸟-设计模式

20 | 并发容器:都有哪些“坑”需要我们填?

2019/12/05 posted in  极客-Java并发实战

20 | 方法内联(上)

2019/12/14 posted in  极客-深入拆解Java虚拟机

20 散列表下:为什么散列表和链表经常一起使用?

LRU 缓存淘汰算法

一个缓存(cache)系统主要包含下面这几个操作:

  • 往缓存中添加一个数据;
  • 从缓存中删除一个数据;
  • 在缓存中查找一个数据。

这三个操作都要涉及“查找”操作,如果单纯地采用链表的话,时间复杂度只能是 O(n)。如果我们将散列表和链表两种数据结构组合使用,可以将这三个操作的时间复杂度都降低到 O(1)。具体的结构就是下面这个样子:

散列表通过链表法解决散列冲突的,所以每个结点会在两条链中。一个链是刚刚我们提到的双向链表,另一个链是散列表中的拉链前驱和后继指针是为了将结点串在双向链表中,hnext 指针是为了将结点串在散列表的拉链中。

  1. 首先,我们来看如何查找一个数据
    1. 散列表中查找数据的时间复杂度接近 O(1),所以通过散列表,我们可以很快地在缓存中找到一个数据。
    2. 当找到数据之后,我们还需要将它移动到双向链表的尾部。
  2. 其次,我们来看如何删除一个数据
    1. 我们需要找到数据所在的结点,然后将结点删除。借助散列表,我们可以在 O(1) 时间复杂度里找到要删除的结点。
    2. 因为我们的链表是双向链表,双向链表可以通过前驱指针 O(1) 时间复杂度获取前驱结点,所以在双向链表中,删除结点只需要 O(1) 的时间复杂度。
  3. 最后,我们来看如何添加一个数据
    1. 我们需要先看这个数据是否已经在缓存中。如果已经在其中,需要将其移动到双向链表的尾部。
    2. 如果不在其中,还要看缓存有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾部。

Redis 有序集合

在跳表那一节,讲到有序集合的操作时,我稍微做了些简化。实际上,在有序集合中,每个成员对象有两个重要的属性,key(键值)和 score(分值)。我们不仅会通过 score 来查找数据,还会通过 key 来查找数据。

所以,如果我们细化一下 Redis 有序集合的操作,那就是下面这样:

  • 添加一个成员对象;
  • 按照键值来删除一个成员对象;
  • 按照键值来查找一个成员对象;
  • 按照分值区间查找数据,比如查找积分在 [100, 356] 之间的成员对象;
  • 按照分值从小到大排序成员变量;

如果我们仅仅按照分值将成员对象组织成跳表的结构,那按照键值来删除、查询成员对象就会很慢,解决方法与 LRU 缓存淘汰算法的解决方法类似。我们可以再按照键值构建一个散列表,这样按照 key 来删除、查找一个成员对象的时间复杂度就变成了 O(1)。同时,借助跳表结构,其他操作也非常高效。

Java LinkedHashMap

LinkedHashMap 是通过散列表和链表组合在一起实现的。它不仅支持按照插入顺序遍历数据,还支持按照访问顺序来遍历数据。

// 10是初始大小,0.75是装载因子,true是表示按照访问时间排序
HashMap<Integer, Integer> m = new LinkedHashMap<>(10, 0.75f, true);
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);

m.put(3, 26);
m.get(5);

for (Map.Entry e : m.entrySet()) {
  System.out.println(e.getKey());
}

这段代码打印的结果是 1,2,3,5。

分析:

在前四个操作完成之后,链表中的数据是下面这样:

m.put(3, 26);后:

m.get(5);后:

按照访问时间排序的 LinkedHashMap 本身就是一个支持 LRU 缓存淘汰策略的缓存系统。
LinkedHashMap 是通过双向链表和散列表这两种数据结构组合实现的。LinkedHashMap 中的“Linked”实际上是指的是双向链表,并非指用链表法解决散列冲突。

解答开篇 & 内容小结

为什么散列表和链表经常一块使用?

散列表这种数据结构虽然支持非常高效的数据插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的。也就说,它无法支持按照某种顺序快速地遍历数据。如果希望按照顺序遍历散列表中的数据,那我们需要将散列表中的数据拷贝到数组中,然后排序,再遍历。

因为散列表是动态数据结构,不停地有数据的插入、删除,所以每当我们希望按顺序遍历散列表中的数据的时候,都需要先排序,那效率势必会很低。为了解决这个问题,我们将散列表和链表(或者跳表)结合在一起使用。

2019/05/17 posted in  极客-数据结构与算法之美

20 适配器模式

不兼容结构的协调——适配器模式

适配器模式(Adapter Pattern)是作为两个不兼容的接口之间的桥梁。这种类型的设计模式属于结构型模式,它结合了两个独立接口的功能。
这种模式涉及到一个单一的类,该类负责加入独立的或不兼容的接口功能。举个真实的例子,读卡器是作为内存卡和笔记本之间的适配器。您将内存卡插入读卡器,再将读卡器插入笔记本,这样就可以通过笔记本来读取内存卡。
我们通过下面的实例来演示适配器模式的使用。其中,音频播放器设备只能播放 mp3 文件,通过使用一个更高级的音频播放器来播放 vlc 和 mp4 文件。

介绍

意图:将一个类的接口转换成客户希望的另外一个接口。适配器模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。
主要解决:主要解决在软件系统中,常常要将一些"现存的对象"放到新的环境中,而新环境要求的接口是现对象不能满足的。
何时使用: 1、系统需要使用现有的类,而此类的接口不符合系统的需要。 2、想要建立一个可以重复使用的类,用于与一些彼此之间没有太大关联的一些类,包括一些可能在将来引进的类一起工作,这些源类不一定有一致的接口。 3、通过接口转换,将一个类插入另一个类系中。(比如老虎和飞禽,现在多了一个飞虎,在不增加实体的需求下,增加一个适配器,在里面包容一个虎对象,实现飞的接口。)
如何解决:继承或依赖(推荐)。
关键代码:适配器继承或依赖已有的对象,实现想要的目标接口。
应用实例: 1、美国电器 110V,中国 220V,就要有一个适配器将 110V 转化为 220V。 2、JAVA JDK 1.1 提供了 Enumeration 接口,而在 1.2 中提供了 Iterator 接口,想要使用 1.2 的 JDK,则要将以前系统的 Enumeration 接口转化为 Iterator 接口,这时就需要适配器模式。 3、在 LINUX 上运行 WINDOWS 程序。 4、JAVA 中的 jdbc。
优点: 1、可以让任何两个没有关联的类一起运行。 2、提高了类的复用。 3、增加了类的透明度。 4、灵活性好。
缺点: 1、过多地使用适配器,会让系统非常零乱,不易整体进行把握。比如,明明看到调用的是 A 接口,其实内部被适配成了 B 接口的实现,一个系统如果太多出现这种情况,无异于一场灾难。因此如果不是很有必要,可以不使用适配器,而是直接对系统进行重构。 2.由于 JAVA 至多继承一个类,所以至多只能适配一个适配者类,而且目标类必须是抽象类。
使用场景:有动机地修改一个正常运行的系统的接口,这时应该考虑使用适配器模式。
注意事项:适配器不是在详细设计时添加的,而是解决正在服役的项目的问题。

2019/11/01 posted in  菜鸟-设计模式

21 | 原子类:无锁工具类的典范

2019/12/05 posted in  极客-Java并发实战

21 | 哈希算法(上):如何防止数据库中的用户信息被脱库?

今天从实战的角度告诉你,在实际的开发中,我们该如何用哈希算法解决问题。

什么是哈希算法?

将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值

如何设计一个优秀的哈希算法?

要求:

  • 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法);
  • 对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同;
  • 散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小;
  • 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。

哈希算法的应用非常非常多,我选了最常见的七个,分别是安全加密、唯一标识、数据校验、散列函数、负载均衡、数据分片、分布式存储。

应用一:安全加密

最常用于加密的哈希算法是 MD5(MD5 Message-Digest Algorithm,MD5 消息摘要算法)和 SHA(Secure Hash Algorithm,安全散列算法)。

除了这两个之外,当然还有很多其他加密算法,比如 DES(Data Encryption Standard,数据加密标准)、AES(Advanced Encryption Standard,高级加密标准)。

为什么哈希算法无法做到零冲突?

哈希算法产生的哈希值的长度是固定且有限的,而我们要哈希的数据是无穷的。

哈希值越长的哈希算法,散列冲突的概率越低。即便哈希算法存在冲突,但是在有限的时间和资源下,哈希算法还是被很难破解的。我们在实际的开发过程中,也需要权衡破解难度和计算时间,来决定究竟使用哪种加密算法。

应用二:唯一标识

如何在海量的图库中,搜索一张图是否存在?

  1. 从图片的二进制码串开头取 100 个字节,从中间取 100 个字节,从最后再取 100 个字节,然后将这 300 个字节放到一块,通过哈希算法(比如 MD5),得到一个哈希字符串,用它作为图片的唯一标识。
  2. 把每个图片的唯一标识,和相应的图片文件在图库中的路径信息,都存储在散列表中。当要查看某个图片是不是在图库中的时候,我们先通过哈希算法对这个图片取唯一标识,然后在散列表中查找是否存在这个唯一标识。

应用三:数据校验

如何来校验文件块的安全、正确、完整呢?

我们通过哈希算法,对文件块分别取哈希值,并且保存在种子文件中。哈希算法有一个特点,对数据很敏感。只要文件块的内容有一丁点儿的改变,最后计算出的哈希值就会完全不同。所以,当文件块下载完成之后,我们可以通过相同的哈希算法,对下载好的文件块逐一求哈希值,然后跟种子文件中保存的哈希值比对。如果不同,说明这个文件块不完整或者被篡改了,需要再重新从其他宿主机器上下载这个文件块。

应用四:散列函数

  1. 相对哈希算法的其他应用,散列函数对于散列算法冲突的要求要低很多。
  2. 散列函数对于散列算法计算得到的值,是否能反向解密也并不关心。
  3. 散列函数执行的快慢,会影响散列表的性能,所以,散列函数用的散列算法一般都比较简单,比较追求效率。

解答开篇

如何防止数据库中的用户信息被脱库?

字典攻击你听说过吗?如果用户信息被“脱库”,黑客虽然拿到是加密之后的密文,但可以通过“猜”的方式来破解密码,这是因为,有些用户的密码太简单。比如很多人习惯用 00000、123456 这样的简单数字组合做密码,很容易就被猜中。那我们就需要维护一个常用密码的字典表,把字典中的每个密码用哈希算法计算哈希值,然后拿哈希值跟脱库后的密文比对。如果相同,基本上就可以认为,这个加密之后的密码对应的明文就是字典中的这个密码。

针对字典攻击,我们可以引入一个盐(salt),跟用户的密码组合在一起,增加密码的复杂度。我们拿组合之后的字符串来做哈希算法加密,将它存储到数据库中,进一步增加破解的难度。

2019/11/20 posted in  极客-数据结构与算法之美

21 | 方法内联(下)

2019/12/14 posted in  极客-深入拆解Java虚拟机

21 装饰模式

扩展系统功能——装饰模式

装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其结构。这种类型的设计模式属于结构型模式,它是作为现有的类的一个包装。
这种模式创建了一个装饰类,用来包装原有的类,并在保持类方法签名完整性的前提下,提供了额外的功能。
我们通过下面的实例来演示装饰器模式的用法。其中,我们将把一个形状装饰上不同的颜色,同时又不改变形状类。

介绍

意图:动态地给一个对象添加一些额外的职责。就增加功能来说,装饰器模式相比生成子类更为灵活。
主要解决:一般的,我们为了扩展一个类经常使用继承方式实现,由于继承为类引入静态特征,并且随着扩展功能的增多,子类会很膨胀。
何时使用:在不想增加很多子类的情况下扩展类。
如何解决:将具体功能职责划分,同时继承装饰者模式。
关键代码: 1、Component 类充当抽象角色,不应该具体实现。 2、修饰类引用和继承 Component 类,具体扩展类重写父类方法。
应用实例: 1、孙悟空有 72 变,当他变成"庙宇"后,他的根本还是一只猴子,但是他又有了庙宇的功能。 2、不论一幅画有没有画框都可以挂在墙上,但是通常都是有画框的,并且实际上是画框被挂在墙上。在挂在墙上之前,画可以被蒙上玻璃,装到框子里;这时画、玻璃和画框形成了一个物体。
优点:装饰类和被装饰类可以独立发展,不会相互耦合,装饰模式是继承的一个替代模式,装饰模式可以动态扩展一个实现类的功能。
缺点:多层装饰比较复杂。
使用场景: 1、扩展一个类的功能。 2、动态增加功能,动态撤销。
注意事项:可代替继承。

2019/11/01 posted in  菜鸟-设计模式

22 | Executor与线程池:如何创建正确的线程池?

2019/12/05 posted in  极客-Java并发实战

22 | HotSpot虚拟机的intrinsic

2019/12/14 posted in  极客-深入拆解Java虚拟机

22 | 哈希算法(下):哈希算法在分布式系统中有哪些应用?

今天,我们再来看剩余三种跟分布式系统有关的应用:负载均衡、数据分片、分布式存储。我们来看下,哈希算法是如何解决这些分布式问题的

应用五:负载均衡

负载均衡算法有很多,比如轮询、随机、加权轮询等。那如何才能实现一个会话粘滞(session sticky)的负载均衡算法呢?也就是说,我们需要在同一个客户端上,在一次会话中的所有请求都路由到同一个服务器上。

最直接的方法就是,维护一张映射关系表,这张表的内容是客户端 IP 地址或者会话 ID 与服务器编号的映射关系。客户端发出的每次请求,都要先在映射表中查找应该路由到的服务器编号,然后再请求编号对应的服务器。

这种方法简单直观,但也有几个弊端:

  • 如果客户端很多,映射表可能会很大,比较浪费内存空间;
  • 客户端下线、上线,服务器扩容、缩容都会导致映射失效,这样维护映射表的成本就会很大;

如果借助哈希算法,这些问题都可以非常完美地解决。我们可以通过哈希算法,对客户端 IP 地址或者会话 ID 计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。 这样,我们就可以把同一个 IP 过来的所有请求,都路由到同一个后端服务器上。

应用六:数据分片

1. 如何统计“搜索关键词”出现的次数?

假如我们有 1T 的日志文件,这里面记录了用户的搜索关键词,我们想要快速统计出每个关键词被搜索的次数,该怎么做呢?

这个问题有两个难点,第一个是搜索日志很大,没办法放到一台机器的内存中。第二个难点是,如果只用一台机器来处理这么巨大的数据,处理时间会很长。

针对这两个难点,我们可以先对数据进行分片,然后采用多台机器处理的方法,来提高处理速度。具体的思路是这样的:为了提高处理的速度,我们用 n 台机器并行处理。我们从搜索记录的日志文件中,依次读出每个搜索关键词,并且通过哈希函数计算哈希值,然后再跟 n 取模,最终得到的值,就是应该被分配到的机器编号。

这样,哈希值相同的搜索关键词就被分配到了同一个机器上。也就是说,同一个搜索关键词会被分配到同一个机器上。每个机器会分别计算关键词出现的次数,最后合并起来就是最终的结果。实际上,这里的处理过程也是 MapReduce 的基本设计思想。

2. 如何快速判断图片是否在图库中?

如何快速判断图片是否在图库中?上一节我们讲过这个例子,不知道你还记得吗?当时我介绍了一种方法,即给每个图片取唯一标识(或者信息摘要),然后构建散列表。假设现在我们的图库中有 1 亿张图片,很显然,在单台机器上构建散列表是行不通的。因为单台机器的内存有限,而 1 亿张图片构建散列表显然远远超过了单台机器的内存上限。

  1. 我们同样可以对数据进行分片,然后采用多机处理。我们准备 n 台机器,让每台机器只维护某一部分图片对应的散列表。
  2. 我们每次从图库中读取一个图片,计算唯一标识,然后与机器个数 n 求余取模,得到的值就对应要分配的机器编号,然后将这个图片的唯一标识和图片路径发往对应的机器构建散列表。
  3. 当我们要判断一个图片是否在图库中的时候,我们通过同样的哈希算法,计算这个图片的唯一标识,然后与机器个数 n 求余取模。假设得到的值是 k,那就去编号 k 的机器构建的散列表中查找。

我们来估算一下,给这 1 亿张图片构建散列表大约需要多少台机器。

  1. 散列表中每个数据单元包含两个信息,哈希值和图片文件的路径。假设我们通过 MD5 来计算哈希值,那长度就是 128 比特,也就是 16 字节。文件路径长度的上限是 256 字节,我们可以假设平均长度是 128 字节。如果我们用链表法来解决冲突,那还需要存储指针,指针只占用 8 字节。所以,散列表中每个数据单元就占用 152 字节(这里只是估算,并不准确)。
  2. 假设一台机器的内存大小为 2GB,散列表的装载因子为 0.75,那一台机器可以给大约 1000 万(2GB*0.75/152)张图片构建散列表。所以,如果要对 1 亿张图片构建索引,需要大约十几台机器。

在工程中,这种估算还是很重要的,能让我们事先对需要投入的资源、资金有个大概的了解,能更好地评估解决方案的可行性。实际上,针对这种海量数据的处理问题,我们都可以采用多机分布式处理。借助这种分片的思路,可以突破单机内存、CPU 等资源的限制。

应用七:分布式存储

现在互联网面对的都是海量的数据、海量的用户。我们为了提高数据的读取、写入能力,一般都采用分布式的方式来存储数据,比如分布式缓存。我们有海量的数据需要缓存,所以一个缓存机器肯定是不够的。于是,我们就需要将数据分布在多台机器上。该如何决定将哪个数据放到哪个机器上呢?

我们可以借用前面数据分片的思想,即通过哈希算法对数据取哈希值,然后对机器个数取模,这个最终值就是应该存储的缓存机器编号。但是,如果数据增多,原来的 10 个机器已经无法承受了,我们就需要扩容了,比如扩到 11 个机器,这时候麻烦就来了。因为,这里并不是简单地加个机器就可以了。原来的数据是通过与 10 来取模的。比如 13 这个数据,存储在编号为 3 这台机器上。但是新加了一台机器中,我们对数据按照 11 取模,原来 13 这个数据就被分配到 2 号这台机器上了。

所有的数据都要重新计算哈希值,然后重新搬移到正确的机器上。这样就相当于,缓存中的数据一下子就都失效了。所有的数据请求都会穿透缓存,直接去请求数据库。这样就可能发生雪崩效应,压垮数据库。

所以,我们需要一种方法,使得在新加入一个机器后,并不需要做大量的数据搬移。这时候,一致性哈希算法就要登场了。

假设我们有 k 个机器,数据的哈希值的范围是 [0, MAX]。我们将整个范围划分成 m 个小区间(m 远大于 k),每个机器负责 m/k 个小区间。当有新机器加入的时候,我们就将某几个小区间的数据,从原来的机器中搬移到新的机器中。这样,既不用全部重新哈希、搬移数据,也保持了各个机器上数据数量的均衡。

一致性哈希算法的基本思想就是这么简单。除此之外,它还会借助一个虚拟的环和虚拟结点,更加优美地实现出来。

解答开篇 & 内容小结

在负载均衡应用中,利用哈希算法替代映射表,可以实现一个会话粘滞的负载均衡策略。在数据分片应用中,通过哈希算法对处理的海量数据进行分片,多机分布式处理,可以突破单机资源的限制。在分布式存储应用中,利用一致性哈希算法,可以解决缓存等分布式系统的扩容、缩容导致数据大量搬移的难题。

2019/11/20 posted in  极客-数据结构与算法之美

22 享元模式

实现对象的复用——享元模式

享元模式(Flyweight Pattern)主要用于减少创建对象的数量,以减少内存占用和提高性能。这种类型的设计模式属于结构型模式,它提供了减少对象数量从而改善应用所需的对象结构的方式。
享元模式尝试重用现有的同类对象,如果未找到匹配的对象,则创建新对象。我们将通过创建 5 个对象来画出 20 个分布于不同位置的圆来演示这种模式。由于只有 5 种可用的颜色,所以 color 属性被用来检查现有的 Circle 对象。

介绍

意图:运用共享技术有效地支持大量细粒度的对象。
主要解决:在有大量对象时,有可能会造成内存溢出,我们把其中共同的部分抽象出来,如果有相同的业务请求,直接返回在内存中已有的对象,避免重新创建。
何时使用: 1、系统中有大量对象。 2、这些对象消耗大量内存。 3、这些对象的状态大部分可以外部化。 4、这些对象可以按照内蕴状态分为很多组,当把外蕴对象从对象中剔除出来时,每一组对象都可以用一个对象来代替。 5、系统不依赖于这些对象身份,这些对象是不可分辨的。
如何解决:用唯一标识码判断,如果在内存中有,则返回这个唯一标识码所标识的对象。
关键代码:用 HashMap 存储这些对象。
应用实例: 1、JAVA 中的 String,如果有则返回,如果没有则创建一个字符串保存在字符串缓存池里面。 2、数据库的数据池。
优点:大大减少对象的创建,降低系统的内存,使效率提高。
缺点:提高了系统的复杂度,需要分离出外部状态和内部状态,而且外部状态具有固有化的性质,不应该随着内部状态的变化而变化,否则会造成系统的混乱。
使用场景: 1、系统有大量相似对象。 2、需要缓冲池的场景。
注意事项: 1、注意划分外部状态和内部状态,否则可能会引起线程安全问题。 2、这些类必须有一个工厂对象加以控制。

2019/11/01 posted in  菜鸟-设计模式

23 | Future:如何用多线程实现最优的“烧水泡茶”程序?

2019/12/05 posted in  极客-Java并发实战

23 | 二叉树基础(上):什么样的二叉树适合用数组来存储?

树(Tree)

什么是“树”?

这里面每个元素我们叫作“节点”;用来连线相邻节点之间的关系,我们叫作“父子关系”。父节点,子节点,兄弟节点,根节点,叶(子)节点。

高度(Height)、深度(Depth)、层(Level):

  • 节点的高度 = 节点到叶子节点的最长路径(边数)
  • 节点的深度 = 根节点到这个节点所经历的边的个数
  • 节点的层数 = 节点的深度+1
  • 树的高度 = 根节点的高度

如图:

二叉树(Binary Tree)

二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。也可以有只一个节点。

如图:

编号 2 的二叉树中,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫作满二叉树

编号 3 的二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫作完全二叉树

要理解完全二叉树定义的由来,我们需要先了解,如何表示(或者存储)一棵二叉树?

想要存储一棵二叉树,我们有两种方法,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法。

链式存储法如图所示,每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式我们比较常用。大部分二叉树代码都是通过这种结构来实现的。

下面是基于数组的顺序存储法:

如果节点存储在数组中下标为 i 的位置,那么:

  • 左子节点下标: 2 * i
  • 右子节点下标: 2 * i + 1
  • 父节点下标:i / 2

如果是非完全二叉树,会浪费比较多的数组存储空间:

所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。

当我们讲到堆和堆排序的时候,你会发现,堆其实就是一种完全二叉树,最常用的存储方式就是数组。

二叉树的遍历

如何将所有节点都遍历打印出来呢?经典的方法有三种,前序遍历、中序遍历和后序遍历。其中,前、中、后序,表示的是节点与它的左右子树节点遍历打印的先后顺序。

  • 前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。根左右
  • 中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。左根右
  • 后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。左右根

实际上,二叉树的前、中、后序遍历就是一个递归的过程。写递归代码的关键,就是看能不能写出递推公式,而写递推公式的关键就是,如果要解决问题 A,就假设子问题 B、C 已经解决,然后再来看如何利用 B、C 来解决 A。所以,我们可以把前、中、后序遍历的递推公式都写出来。

前序遍历的递推公式:
preOrder(r) = print r->preOrder(r->left)->preOrder(r->right)

中序遍历的递推公式:
inOrder(r) = inOrder(r->left)->print r->inOrder(r->right)

后序遍历的递推公式:
postOrder(r) = postOrder(r->left)->postOrder(r->right)->print r

二叉树遍历的时间复杂度是多少?
个节点最多会被访问两次,所以遍历操作的时间复杂度,跟节点的个数 n 成正比,也就是说二叉树遍历的时间复杂度是 O(n)

解答开篇 & 内容小结

今天,我讲了一种非线性表数据结构,树。关于树,有几个比较常用的概念你需要掌握,那就是:根节点、叶子节点、父节点、子节点、兄弟节点,还有节点的高度、深度、层数,以及树的高度。

我们平时最常用的树就是二叉树。二叉树的每个节点最多有两个子节点,分别是左子节点和右子节点。二叉树中,有两种比较特殊的树,分别是满二叉树和完全二叉树。满二叉树又是完全二叉树的一种特殊情况。

二叉树既可以用链式存储,也可以用数组顺序存储。数组顺序存储的方式比较适合完全二叉树,其他类型的二叉树用数组存储会比较浪费存储空间。除此之外,二叉树里非常重要的操作就是前、中、后序遍历操作,遍历的时间复杂度是 O(n),你需要理解并能用递归代码来实现。

2019/11/20 posted in  极客-数据结构与算法之美

23 | 逃逸分析

2019/12/14 posted in  极客-深入拆解Java虚拟机

23 外观模式

外观模式(Facade Pattern)隐藏系统的复杂性,并向客户端提供了一个客户端可以访问系统的接口。这种类型的设计模式属于结构型模式,它向现有的系统添加一个接口,来隐藏系统的复杂性。
这种模式涉及到一个单一的类,该类提供了客户端请求的简化方法和对现有系统类方法的委托调用。

介绍

意图:为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。
主要解决:降低访问复杂系统的内部子系统时的复杂度,简化客户端与之的接口。
何时使用: 1、客户端不需要知道系统内部的复杂联系,整个系统只需提供一个"接待员"即可。 2、定义系统的入口。
如何解决:客户端不与系统耦合,外观类与系统耦合。
关键代码:在客户端和复杂系统之间再加一层,这一层将调用顺序、依赖关系等处理好。
应用实例: 1、去医院看病,可能要去挂号、门诊、划价、取药,让患者或患者家属觉得很复杂,如果有提供接待人员,只让接待人员来处理,就很方便。 2、JAVA 的三层开发模式。
优点: 1、减少系统相互依赖。 2、提高灵活性。 3、提高了安全性。
缺点:不符合开闭原则,如果要改东西很麻烦,继承重写都不合适。
使用场景: 1、为复杂的模块或子系统提供外界访问的模块。 2、子系统相对独立。 3、预防低水平人员带来的风险。
注意事项:在层次化结构中,可以使用外观模式定义系统中每一层的入口。

2019/11/01 posted in  菜鸟-设计模式

24 | CompletableFuture:异步编程没那么难

2019/12/05 posted in  极客-Java并发实战

24 | 二叉树基础(下):有了如此高效的散列表,为什么还需要二叉树?

二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作。
散列表也是支持这些操作的,并且散列表的这些操作比二叉查找树更高效,时间复杂度是 O(1)。既然有了这么高效的散列表,使用二叉树的地方是不是都可以替换成散列表呢?有没有哪些地方是散列表做不了,必须要用二叉树来做的呢?

二叉查找树(Binary Search Tree)

二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。

1. 二叉查找树的查找操作

  • 我们先取根节点,如果它等于我们要查找的数据,那就返回;
  • 如果要查找的数据比根节点的值小,那就在左子树中递归查找;
  • 如果要查找的数据比根节点的值大,那就在右子树中递归查找。

2. 二叉查找树的插入操作

  • 如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。
  • 如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。

3. 二叉查找树的删除操作

  • 第一种情况是,如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为 null。比如图中的删除节点 55。
  • 第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如图中的删除节点 13。
  • 第三种情况是,如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点。比如图中的删除节点 18。

4. 二叉查找树的其他操作

*** 二叉查找树中还可以支持快速地查找最大节点和最小节点、前驱节点和后继节点。**

  • 中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),非常高效。因此,二叉查找树也叫作二叉排序树。 ### 支持重复数据的二叉查找树

如果存储的两个对象键值相同,这种情况该怎么处理呢?我这里有两种解决方法。
第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
第二种方法,在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。

二叉查找树的时间复杂度分析

二叉查找树的形态各式各样。

解答开篇

散列表的插入、删除、查找操作的时间复杂度可以做到常量级的 O(1),非常高效。而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是 O(logn),那我们为什么还要用二叉查找树呢?

  1. 第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。
  2. 第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)。
  3. 第三,笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
  4. 第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。

内容小结

今天我们学习了一种特殊的二叉树,二叉查找树。它支持快速地查找、插入、删除操作。

二叉查找树中,每个节点的值都大于左子树节点的值,小于右子树节点的值。不过,这只是针对没有重复数据的情况。对于存在重复数据的二叉查找树,我介绍了两种构建方法,一种是让每个节点存储多个值相同的数据;另一种是,每个节点中存储一个数据。针对这种情况,我们只需要稍加改造原来的插入、删除、查找操作即可。在二叉查找树中,查找、插入、删除等很多操作的时间复杂度都跟树的高度成正比。两个极端情况的时间复杂度分别是 O(n) 和 O(logn),分别对应二叉树退化成链表的情况和完全二叉树。为了避免时间复杂度的退化,针对二叉查找树,我们又设计了一种更加复杂的树,平衡二叉查找树,时间复杂度可以做到稳定的 O(logn),下一节我们具体来讲。

2019/11/20 posted in  极客-数据结构与算法之美

24 | 字段访问相关优化

2019/12/14 posted in  极客-深入拆解Java虚拟机

24 桥接模式

处理多维度变化——桥接模式

桥接(Bridge)是用于把抽象化与实现化解耦,使得二者可以独立变化。这种类型的设计模式属于结构型模式,它通过提供抽象化和实现化之间的桥接结构,来实现二者的解耦。
这种模式涉及到一个作为桥接的接口,使得实体类的功能独立于接口实现类。这两种类型的类可被结构化改变而互不影响。
我们通过下面的实例来演示桥接模式(Bridge Pattern)的用法。其中,可以使用相同的抽象类方法但是不同的桥接实现类,来画出不同颜色的圆。

介绍

意图:将抽象部分与实现部分分离,使它们都可以独立的变化。
主要解决:在有多种可能会变化的情况下,用继承会造成类爆炸问题,扩展起来不灵活。
何时使用:实现系统可能有多个角度分类,每一种角度都可能变化。
如何解决:把这种多角度分类分离出来,让它们独立变化,减少它们之间耦合。
关键代码:抽象类依赖实现类。
应用实例: 1、猪八戒从天蓬元帅转世投胎到猪,转世投胎的机制将尘世划分为两个等级,即:灵魂和肉体,前者相当于抽象化,后者相当于实现化。生灵通过功能的委派,调用肉体对象的功能,使得生灵可以动态地选择。 2、墙上的开关,可以看到的开关是抽象的,不用管里面具体怎么实现的。
优点: 1、抽象和实现的分离。 2、优秀的扩展能力。 3、实现细节对客户透明。
缺点:桥接模式的引入会增加系统的理解与设计难度,由于聚合关联关系建立在抽象层,要求开发者针对抽象进行设计与编程。
使用场景: 1、如果一个系统需要在构件的抽象化角色和具体化角色之间增加更多的灵活性,避免在两个层次之间建立静态的继承联系,通过桥接模式可以使它们在抽象层建立一个关联关系。 2、对于那些不希望使用继承或因为多层次继承导致系统类的个数急剧增加的系统,桥接模式尤为适用。 3、一个类存在两个独立变化的维度,且这两个维度都需要进行扩展。
注意事项:对于两个独立变化的维度,使用桥接模式再适合不过了。

2019/11/01 posted in  菜鸟-设计模式

25 | CompletionService:如何批量执行异步任务?

2019/12/05 posted in  极客-Java并发实战

25 | 循环优化

2019/12/14 posted in  极客-深入拆解Java虚拟机

25 | 红黑树(上):为什么工程中都用红黑树这种二叉树?

什么是“平衡二叉查找树”?

平衡二叉树的严格定义是这样的:二叉树中任意一个节点的左右子树的高度相差不能大于 1。完全二叉树、满二叉树其实都是平衡二叉树。

但是很多平衡二叉查找树其实并没有严格符合上面的定义(树中任意一个节点的左右子树的高度相差不能大于 1)。
平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。

如何定义一棵“红黑树”?

红黑树的英文是“Red-Black Tree”,简称 R-B Tree。

  • 根节点是黑色的;
  • 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
  • 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
  • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;

为什么说红黑树是“近似平衡”的?

“平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化的太严重。

如果要证明红黑树是近似平衡的,我们只需要分析,红黑树的高度是否比较稳定地趋近 log2n 就好了。

首先,我们来看,如果我们将红色节点从红黑树中去掉,那单纯包含黑色节点的红黑树的高度是多少呢?

我们现在知道只包含黑色节点的“黑树”的高度,那我们现在把红色节点加回去,高度会变成多少呢?

解答开篇

为什么在工程中大家都喜欢用红黑树这种平衡二叉查找树?

  1. Treap、Splay Tree,绝大部分情况下,它们操作的效率都很高,但是也无法避免极端情况下时间复杂度的退化。
  2. AVL 树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,AVL 树为了维持这种高度的平衡,就要付出更多的代价。
  3. 红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比 AVL 树要低。

红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。

内容小结

红黑树是一种平衡二叉查找树。它是为了解决普通二叉查找树在数据更新的过程中,复杂度退化的问题而产生的。红黑树的高度近似 log2n,所以它是近似平衡,插入、删除、查找操作的时间复杂度都是 O(logn)。

因为红黑树是一种性能非常稳定的二叉查找树,所以,在工程中,但凡是用到动态插入、删除、查找数据的场景,都可以用到它。不过,它实现起来比较复杂,如果自己写代码实现,难度会有些高,这个时候,我们其实更倾向用跳表来替代它。

2019/11/20 posted in  极客-数据结构与算法之美

26 | Fork/Join:单机版的MapReduce

2019/12/05 posted in  极客-Java并发实战

26 | 向量化

2019/12/14 posted in  极客-深入拆解Java虚拟机

26 | 红黑树(下):掌握这些技巧,你也可以实现一个红黑树

实现红黑树的基本思想

一棵合格的红黑树需要满足这样几个要求:

  • 根节点是黑色的;
  • 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
  • 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
  • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点。

在插入、删除节点的过程中,第三、第四点要求可能会被破坏,而我们今天要讲的“平衡调整”,实际上就是要把被破坏的第三、第四点恢复过来。

在正式开始之前,我先介绍两个非常重要的操作:

  • 左旋(rotate left):围绕某个节点的左旋。
  • 右旋(rotate right):围绕某个节点的右旋。

插入操作的平衡调整

红黑树规定,插入的节点必须是红色的。而且,二叉查找树中新插入的节点都是放在叶子节点上。所以,关于插入操作的平衡调整,有这样两种特殊情况,但是也都非常好处理。

  • 如果插入节点的父节点是黑色的,那我们什么都不用做,它仍然满足红黑树的定义。
  • 如果插入的节点是根节点,那我们直接改变它的颜色,把它变成黑色就可以了。

除此之外,其他情况都会违背红黑树的定义,于是我们就需要进行调整,调整的过程包含两种基础的操作:左右旋转和改变颜色。

删除操作的平衡调整

删除操作的平衡调整分为两步,第一步是针对删除节点初步调整。初步调整只是保证整棵红黑树在一个节点删除之后,仍然满足最后一条定义的要求,也就是说,每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;第二步是针对关注节点进行二次调整,让它满足红黑树的第三条定义,即不存在相邻的两个红色节点。

内容小结

第一点,把红黑树的平衡调整的过程比作魔方复原,不要过于深究这个算法的正确性。你只需要明白,只要按照固定的操作步骤,保持插入、删除的过程,不破坏平衡树的定义就行了。

第二点,找准关注节点,不要搞丢、搞错关注节点。因为每种操作规则,都是基于关注节点来做的,只有弄对了关注节点,才能对应到正确的操作规则中。在迭代的调整过程中,关注节点在不停地改变,所以,这个过程一定要注意,不要弄丢了关注节点。

第三点,插入操作的平衡调整比较简单,但是删除操作就比较复杂。针对删除操作,我们有两次调整,第一次是针对要删除的节点做初步调整,让调整后的红黑树继续满足第四条定义,“每个节点到可达叶子节点的路径都包含相同个数的黑色节点”。但是这个时候,第三条定义就不满足了,有可能会存在两个红色节点相邻的情况。第二次调整就是解决这个问题,让红黑树不存在相邻的红色节点。课后思考

2019/11/20 posted in  极客-数据结构与算法之美

27 | 并发工具类模块热点问题答疑

2019/12/05 posted in  极客-Java并发实战

27 | 注解处理器

2019/12/14 posted in  极客-深入拆解Java虚拟机

27 | 递归树:如何借助树来求解递归算法的时间复杂度?

递归树与时间复杂度分析

递归的思想就是,将大问题分解为小问题来求解,然后再将小问题分解为小小问题。这样一层一层地分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止。如果我们把这个一层一层的分解过程画成图,它其实就是一棵树。我们给这棵树起一个名字,叫作递归树

如何用递归树来求解时间复杂度。

  1. 因为每次分解都是一分为二,所以代价很低,我们把时间上的消耗记作常量 1。
  2. 我们只需要知道这棵树的高度 h,用高度 h 乘以每一层的时间消耗 n,就可以得到总的时间复杂度 O(n∗h)。
  3. 归并排序递归树是一棵满二叉树。满二叉树的高度大约是 log2​n,所以,归并排序递归实现的时间复杂度就是 O(nlogn)。

实战一:分析快速排序的时间复杂度

实战二:分析斐波那契数列的时间复杂度

实战三:分析全排列的时间复杂度

内容小结

今天,我们用递归树分析了递归代码的时间复杂度。加上我们在排序那一节讲到的递推公式的时间复杂度分析方法,我们现在已经学习了两种递归代码的时间复杂度分析方法了。

有些代码比较适合用递推公式来分析,比如归并排序的时间复杂度、快速排序的最好情况时间复杂度;有些比较适合采用递归树来分析,比如快速排序的平均时间复杂度。而有些可能两个都不怎么适合使用,比如二叉树的递归前中后序遍历。

时间复杂度分析的理论知识并不多,也不复杂,掌握起来也不难,但是,在我们平时的工作、学习中,面对的代码千差万别,能够灵活应用学到的复杂度分析方法,来分析现有的代码,并不是件简单的事情,所以,你平时要多实战、多分析,只有这样,面对任何代码的时间复杂度分析,你才能做到游刃有余、毫不畏惧。

2019/11/20 posted in  极客-数据结构与算法之美

28 | Immutability模式:如何利用不变性解决并发问题?

2019/12/05 posted in  极客-Java并发实战

28 | 基准测试框架JMH(上)

2019/12/14 posted in  极客-深入拆解Java虚拟机

28 | 堆和堆排序:为什么说堆排序没有快速排序快?

堆(Heap),经常被用于堆排序。
堆排序是一种原地的、时间复杂度为O(nlogn)的排序算法。

如何理解堆?

堆是一种特殊的树。只要满足以下两点,就是一个堆:

  • 堆是一个完全二叉树。
  • 堆中每一个节点的值都必需大于等于(或小于等于)其子树中每个节点的值。

节点大于子树节点的堆叫“大顶堆”,否则为“小顶堆”。

1、2是大顶堆,3是小顶堆,4不是堆。

如何实现一个堆?

要实现一个堆,我们先要知道,堆都支持哪些操作以及如何存储一个堆

1.往堆中插入一个元素

如果我们把新插入的元素放到堆中,需要对堆进行调整,让其重新满足堆的特性,这个过程称之为堆化(heapify)。

  • 堆化实际上有两种,从下往上和从上往下。
  • 堆化就是顺着节点所在的路径,向上或者向下,对比,然后交换。

从下往上的堆化:

2.删除堆顶元素

  1. 把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。
  2. 对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。

从上往下的堆化:

一个包含 n 个节点的完全二叉树,树的高度不会超过 log2​n。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 O(logn)。插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是 O(logn)。

如何基于堆实现排序?

1.建堆

  1. 第一种是借助我们前面讲的,在堆中插入一个元素的思路。
  2. 第二种实现思路,是从后往前处理数组,并且每个数据都是从上往下堆化。
    建堆的时间复杂度是O(n)。

2.排序

  1. 我们把堆顶元素跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。
  2. 然后再通过堆化的方法,将剩下的 n−1 个元素重新构建成堆。
  3. 我们再取堆顶的元素,放到下标是 n−1 的位置,一直重复这个过程,直到最后堆中只剩下标为 1 的一个元素,排序工作就完成了。

堆排序是原地排序算法。堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。堆排序不是稳定的排序算法。

解答开篇

在实际开发中,为什么快速排序要比堆排序性能好?
第一点,堆排序数据访问的方式没有快速排序友好。
第二点,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。

内容小结

今天我们讲了堆这种数据结构。堆是一种完全二叉树。它最大的特性是:每个节点的值都大于等于(或小于等于)其子树节点的值。因此,堆被分成了两类,大顶堆和小顶堆。

堆中比较重要的两个操作是插入一个数据和删除堆顶元素。这两个操作都要用到堆化。插入一个数据的时候,我们把新插入的数据放到数组的最后,然后从下往上堆化;删除堆顶数据的时候,我们把数组中的最后一个元素放到堆顶,然后从上往下堆化。这两个操作时间复杂度都是 O(logn)。

除此之外,我们还讲了堆的一个经典应用,堆排序。堆排序包含两个过程,建堆和排序。我们将下标从 n/2​ 到 1 的节点,依次进行从上到下的堆化操作,然后就可以将数组中的数据组织成堆这种数据结构。接下来,我们迭代地将堆顶的元素放到堆的末尾,并将堆的大小减一,然后再堆化,重复这个过程,直到堆中只剩下一个元素,整个数组中的数据就都有序排列了。

2019/11/20 posted in  极客-数据结构与算法之美

29 | Copy-on-Write模式:不是延时策略的COW

2019/12/05 posted in  极客-Java并发实战

29 | 基准测试框架JMH(下)

2019/12/14 posted in  极客-深入拆解Java虚拟机

29 | 堆的应用:如何快速获取到Top 10最热门的搜索关键词?

堆的应用一:优先级队列

优先级队列,顾名思义,它首先应该是一个队列(先进先出)。不过,在优先级队列中,数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队。

为什么用堆来实现一个优先级队列呢?堆和优先级队列非常相似。一个堆就可以看作一个优先级队列。往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素。

应用场景赫夫曼编码、图的最短路径、最小生成树算法等等,还有Java 的 PriorityQueue,C++ 的 priority_queue 等。

1. 合并有序小文件

假设我们有 100 个小文件,每个文件的大小是 100MB,每个文件中存储的都是有序的字符串。我们希望将这些 100 个小文件合并成一个有序的大文件。这里就会用到优先级队列。

  1. 我们将从小文件中取出来的字符串放入到小顶堆中,那堆顶的元素,也就是优先级队列队首的元素,就是最小的字符串。
  2. 我们将这个字符串放入到大文件中,并将其从堆中删除。
  3. 然后再从小文件中取出下一个字符串,放入到堆中。循环这个过程,就可以将 100 个小文件中的数据依次放入到大文件中。

2. 高性能定时器

假设我们有一个定时器,定时器中维护了很多定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间(比如 1 秒),就扫描一遍任务,看是否有任务到达设定的执行时间。如果到达了,就拿出来执行。

  1. 我们按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部(也就是小顶堆的堆顶)存储的是最先执行的任务。
  2. 拿队首任务的执行时间点,与当前时间点相减,得到一个时间间隔 T。定时器就可以设定在 T 秒之后,再来执行任务。
  3. 当 T 秒时间过去之后,定时器取优先级队列中队首的任务执行。然后再计算新的队首任务的执行时间点与当前时间点的差值,把这个值作为定时器执行下一个任务需要等待的时间。

堆的应用二:利用堆求TopK

针对静态数据,如何在一个包含 n 个数据的数组中,查找前 K 大数据呢?

  1. 维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。
  2. 如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。
  3. 这样等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了。

针对动态数据求得 Top K 就是实时 Top K。

  1. 一直都维护一个 K 大小的小顶堆
  2. 当有数据被添加到集合中时,我们就拿它与堆顶的元素对比。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理。

针对动态数据集合,也就是说数据集合事先并不确定,有数据动态地加入到集合中。

堆的应用三:利用堆求中位数

  1. 我们需要维护两个堆,一个大顶堆,一个小顶堆。小顶堆中的数据都大于大顶堆中的数据。如果 n 是偶数,两个堆中的数据个数都是 n/2​;如果 n 是奇数,大顶堆有 n/2+1 个数据,小顶堆有 n/2​ 个数据。
  2. 如果新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;否则,我们就将这个新数据插入到小顶堆。
  3. 如果堆的个数不平均,则从一个堆中不停地将堆顶元素移动到另一个堆。
  4. 中位数就是大顶堆的堆顶元素。

同理“99% 响应时间”也是类似方法。

解答开篇

内容小结

  • 优先级队列是一种特殊的队列,优先级高的数据先出队,而不再像普通的队列那样,先进先出。实际上,堆就可以看作优先级队列,只是称谓不一样罢了。
  • 求 Top K 问题又可以分为针对静态数据和针对动态数据,只需要利用一个堆,就可以做到非常高效率的查询 Top K 的数据。
  • 求中位数实际上还有很多变形,比如求 99 百分位数据、90 百分位数据等,处理的思路都是一样的,即利用两个堆,一个大顶堆,一个小顶堆,随着数据的动态添加,动态调整两个堆中的数据,最后大顶堆的堆顶元素就是要求的数据。
2019/11/20 posted in  极客-数据结构与算法之美

30 | Java虚拟机的监控及诊断工具(命令行篇)

2019/12/14 posted in  极客-深入拆解Java虚拟机

30 | 图的表示:如何存储微博、微信等社交网络中的好友关系?

涉及图的算法有很多,也非常复杂,比如图的搜索、最短路径、最小生成树、二分图等等。

如何理解图?(Graph)

树中的元素我们称为节点,图中的元素我们就叫作顶点(vertex)。图中的一个顶点可以与任意其他顶点建立连接关系。我们把这种建立的关系叫作(edge)。顶点与顶点相连接的边的条数,叫做顶点的(degree)。

边有方向的图叫作“有向图”;边没有方向的图就叫作“无向图”。

在有向图中,我们把度分为入度(In-degree)和出度(Out-degree)。顶点的入度,表示有多少条边指向这个顶点;顶点的出度,表示有多少条边是以这个顶点为起点指向其他顶点。

带权图(weighted graph),每条边都有一个权重(weight)。

邻接矩阵存储方法

图最直观的一种存储方法就是,邻接矩阵(Adjacency Matrix)。
邻接矩阵的底层依赖一个二维数组。对于无向图来说,如果顶点 i 与顶点 j 之间有边,我们就将 A[i][j] 和 A[j][i] 标记为 1;对于有向图来说,如果顶点 i 到顶点 j 之间,有一条箭头从顶点 i 指向顶点 j 的边,那我们就将 A[i][j] 标记为 1。同理,如果有一条箭头从顶点 j 指向顶点 i 的边,我们就将 A[j][i] 标记为 1。对于带权图,数组中就存储相应的权重。

优点:简单、直观;方便计算。
缺点:浪费存储空间。

邻接表存储方法

如图,图中画的是一个有向图的邻接表存储方式,每个顶点对应的链表里面,存储的是指向的顶点。对于无向图来说,也是类似的,不过,每个顶点的链表中存储的,是跟这个顶点有边相连的顶点。

在基于链表法解决冲突的散列表中,如果链过长,为了提高查找效率,我们可以将链表换成其他更加高效的数据结构,比如平衡二叉查找树等。实际开发中,我们可以选择用红黑树。这样,我们就可以更加快速地查找两个顶点之间是否存在边了。当然,这里的二叉查找树可以换成其他动态数据结构,比如跳表、散列表等。除此之外,我们还可以将链表改成有序动态数组,可以通过二分查找的方法来快速定位两个顶点之间否是存在边。

解答开篇

如何存储微博、微信等社交网络中的好友关系?

  • 因为社交网络是一张稀疏图,使用邻接矩阵存储比较浪费存储空间。所以,这里我们采用邻接表来存储。
  • 如果要想知道某个用户都被哪些用户关注了,我们需要一个逆邻接表。
  • 快速判断两个用户之间是否是关注与被关注的关系?因为我们需要按照用户名称的首字母排序,分页来获取用户的粉丝列表或者关注列表,用跳表这种结构再合适不过了。
  • 用户大时,我们可以通过哈希算法等数据分片方式,将邻接表存储在不同的机器上。或者利用外部存储(比如硬盘),用来持久化存储关系数据。

内容小结

今天我们学习了图这种非线性表数据结构,关于图,你需要理解这样几个概念:无向图、有向图、带权图、顶点、边、度、入度、出度。除此之外,我们还学习了图的两个主要的存储方式:邻接矩阵和邻接表。

邻接矩阵存储方法的缺点是比较浪费空间,但是优点是查询效率高,而且方便矩阵运算。邻接表存储方法中每个顶点都对应一个链表,存储与其相连接的其他顶点。尽管邻接表的存储方式比较节省存储空间,但链表不方便查找,所以查询效率没有邻接矩阵存储方式高。针对这个问题,邻接表还有改进升级版,即将链表换成更加高效的动态数据结构,比如平衡二叉查找树、跳表、散列表等。

2019/11/20 posted in  极客-数据结构与算法之美

30 | 线程本地存储模式:没有共享,就没有伤害

2019/12/05 posted in  极客-Java并发实战

31 | Guarded Suspension模式:等待唤醒机制的规范实现

2019/12/05 posted in  极客-Java并发实战

31 | Java虚拟机的监控及诊断工具(GUI篇)

2019/12/14 posted in  极客-深入拆解Java虚拟机

31 | 深度和广度优先搜索:如何找出社交网络中的三度好友关系?

什么是“搜索”算法?

图上的搜索算法,就是在图中找出从一个顶点出发,到另一个顶点的路径。

用邻接表来存储图:

public class Graph { // 无向图
  private int v; // 顶点的个数
  private LinkedList<Integer> adj[]; // 邻接表

  public Graph(int v) {
    this.v = v;
    adj = new LinkedList[v];
    for (int i=0; i<v; ++i) {
      adj[i] = new LinkedList<>();
    }
  }

  public void addEdge(int s, int t) { // 无向图一条边存两次
    adj[s].add(t);
    adj[t].add(s);
  }
}

广度优先搜索(BFS)

广度优先搜索(Breadth-First-Search),简称为 BFS。直观地讲,它其实就是一种“地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索。

V 表示顶点的个数,E 表示边的个数。
广度优先搜索的时间复杂度也可以简写为 O(E)。
广度优先搜索的空间消耗主要在几个辅助变量 visited 数组、queue 队列、prev 数组上。这三个存储空间的大小都不会超过顶点的个数,所以空间复杂度是 O(V)。

深度优先搜索(DFS)

深度优先搜索(Depth-First-Search),简称 DFS。最直观的例子就是“走迷宫”。

深度优先搜索用的是一种比较著名的算法思想,回溯思想。

图上的深度优先搜索算法的时间复杂度是 O(E),E 表示边的个数。
深度优先搜索算法的消耗内存主要是 visited、prev 数组和递归调用栈。visited、prev 数组的大小跟顶点的个数 V 成正比,递归调用栈的最大深度不会超过顶点的个数,所以总的空间复杂度就是 O(V)。

解答开篇

如何找出社交网络中某个用户的三度好友关系?

首先,遍历与起始顶点最近的一层顶点,也就是用户的一度好友,然后再遍历与用户距离的边数为 2 的顶点,也就是二度好友关系,以及与用户距离的边数为 3 的顶点,也就是三度好友关系。

内容小结

广度优先搜索和深度优先搜索是图上的两种最常用、最基本的搜索算法,比起其他高级的搜索算法,比如 A、IDA 等,要简单粗暴,没有什么优化,所以,也被叫作暴力搜索算法。所以,这两种搜索算法仅适用于状态空间不大,也就是说图不大的搜索。

广度优先搜索,通俗的理解就是,地毯式层层推进,从起始顶点开始,依次往外遍历。广度优先搜索需要借助队列来实现,遍历得到的路径就是,起始顶点到终止顶点的最短路径。深度优先搜索用的是回溯思想,非常适合用递归实现。换种说法,深度优先搜索是借助栈来实现的。在执行效率方面,深度优先和广度优先搜索的时间复杂度都是 O(E),空间复杂度是 O(V)。

2019/11/20 posted in  极客-数据结构与算法之美

32 | Balking模式:再谈线程安全的单例模式

2019/12/05 posted in  极客-Java并发实战

32 | JNI的运行机制

2019/12/14 posted in  极客-深入拆解Java虚拟机

32 | 字符串匹配基础(上):如何借助哈希算法实现高效字符串匹配?

单模式串匹配的算法,也就是一个串跟一个串进行匹配,如BF 算法、 RK 算法、BM 算法和 KMP 算法。
多模式串匹配算法,也就是在一个串中同时查找多个串,如 Trie 树和 AC 自动机。

BF算法

BF 算法中的 BF 是 Brute Force 的缩写,中文叫作暴力匹配算法,也叫朴素匹配算法。

我们在字符串 A 中查找字符串 B,那字符串 A 就是主串,字符串 B 就是模式串。我们把主串的长度记作 n,模式串的长度记作 m。因为我们是在主串中查找模式串,所以 n>m。

算法思想:我们在主串中,检查起始位置分别是 0、1、2…n-m 且长度为 m 的 n-m+1 个子串,看有没有跟模式串匹配的。

BF 算法的时间复杂度很高,是 O(n*m),但在实际的开发中,它却是一个比较常用的字符串匹配算法。原因:第一,大部分情况下,模式串和主串的长度都不会太长,不会达到最坏情况;第二,算法思想和实现简单。

RK算法

RK 算法的全称叫 Rabin-Karp 算法,是由它的两位发明者 Rabin 和 Karp 的名字来命名的。

RK 算法的思路是这样的:我们通过哈希算法对主串中的 n-m+1 个子串分别求哈希值,然后逐个与模式串的哈希值比较大小。如果某个子串的哈希值与模式串相等,那就说明对应的子串和模式串匹配了(这里先不考虑哈希冲突的问题,后面我们会讲到)。因为哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和子串比较的效率就提高了。

有没有方法可以提高哈希算法计算子串哈希值的效率呢?这就需要哈希算法设计的非常有技巧了。我们假设要匹配的字符串的字符集中只包含 K 个字符,我们可以用一个 K 进制数来表示一个子串,这个 K 进制数转化成十进制数,作为子串的哈希值。

可以通过设计特殊的哈希算法,只需要扫描一遍主串就能计算出所有子串的哈希值了,所以这部分的时间复杂度是 O(n)。模式串哈希值与每个子串哈希值之间的比较的时间复杂度是 O(1),总共需要比较 n-m+1 个子串的哈希值,所以,这部分的时间复杂度也是 O(n)。所以,RK 算法整体的时间复杂度就是 O(n)。

解答开篇 & 内容小结

今天我们讲了两种字符串匹配算法,BF 算法和 RK 算法。

BF 算法是最简单、粗暴的字符串匹配算法,它的实现思路是,拿模式串与主串中是所有子串匹配,看是否有能匹配的子串。所以,时间复杂度也比较高,是 O(n*m),n、m 表示主串和模式串的长度。不过,在实际的软件开发中,因为这种算法实现简单,对于处理小规模的字符串匹配很好用。

RK 算法是借助哈希算法对 BF 算法进行改造,即对每个子串分别求哈希值,然后拿子串的哈希值与模式串的哈希值比较,减少了比较的时间。所以,理想情况下,RK 算法的时间复杂度是 O(n),跟 BF 算法相比,效率提高了很多。不过这样的效率取决于哈希算法的设计方法,如果存在冲突的情况下,时间复杂度可能会退化。极端情况下,哈希算法大量冲突,时间复杂度就退化为 O(n*m)。

2019/11/20 posted in  极客-数据结构与算法之美

33 | Java Agent与字节码注入

2019/12/14 posted in  极客-深入拆解Java虚拟机

33 | Thread-Per-Message模式:最简单实用的分工方法

2019/12/05 posted in  极客-Java并发实战

33 | 字符串匹配基础(中):如何实现文本编辑器中的查找功能?

  • BM 算法的核心思想
  • BM 算法原理分析
    1. 坏字符规则
    2. 好后缀规则
  • BM 算法代码实现
  • BM 算法的性能分析及优化
  • 解答开篇 & 内容小结

BM 算法的核心思想

BM(Boyer-Moore)算法。
在模式串与主串匹配的过程中,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。

BM 算法原理分析

1. 坏字符规则

我们从模式串的末尾往前倒着匹配,当我们发现某个字符没法匹配的时候。我们把这个没有匹配的字符叫作坏字符(主串中的字符)。

我们拿坏字符 c 在模式串中查找,发现模式串中并不存在这个字符,也就是说,字符 c 与模式串中的任何字符都不可能匹配。这个时候,我们可以将模式串直接往后滑动三位,将模式串滑动到 c 后面的位置,再从模式串的末尾字符开始比较。

这个时候,我们发现,模式串中最后一个字符 d,还是无法跟主串中的 a 匹配,这个时候,还能将模式串往后滑动三位吗?答案是不行的。因为这个时候,坏字符 a 在模式串中是存在的,模式串中下标是 0 的位置也是字符 a。这种情况下,我们可以将模式串往后滑动两位,让两个 a 上下对齐,然后再从模式串的末尾字符开始,重新匹配。

2. 好后缀规则

好后缀规则实际上跟坏字符规则的思路很类似。你看我下面这幅图。当模式串滑动到图中的位置的时候,模式串和主串有 2 个字符是匹配的,倒数第 3 个字符发生了不匹配的情况。

我们把已经匹配的 bc 叫作好后缀,记作{u}。我们拿它在模式串中查找,如果找到了另一个跟{u}相匹配的子串{u*},那我们就将模式串滑动到子串{u*}与主串中{u}对齐的位置。

如果在模式串中找不到另一个等于{u}的子串,我们就直接将模式串,滑动到主串中{u}的后面,因为之前的任何一次往后滑动,都没有匹配主串中{u}的情况。

不过,当模式串中不存在等于{u}的子串时,我们直接将模式串滑动到主串{u}的后面。这样做是否有点太过头呢?我们来看下面这个例子。这里面 bc 是好后缀,尽管在模式串中没有另外一个相匹配的子串{u*},但是如果我们将模式串移动到好后缀的后面,如图所示,那就会错过模式串和主串可以匹配的情况。

如果好后缀在模式串中不存在可匹配的子串,那在我们一步一步往后滑动模式串的过程中,只要主串中的{u}与模式串有重合,那肯定就无法完全匹配。但是当模式串滑动到前缀与主串中{u}的后缀有部分重合的时候,并且重合的部分相等的时候,就有可能会存在完全匹配的情况。

所以,针对这种情况,我们不仅要看好后缀在模式串中,是否有另一个匹配的子串,我们还要考察好后缀的后缀子串,是否存在跟模式串的前缀子串匹配的。

所谓某个字符串 s 的后缀子串,就是最后一个字符跟 s 对齐的子串,比如 abc 的后缀子串就包括 c, bc。所谓前缀子串,就是起始字符跟 s 对齐的子串,比如 abc 的前缀子串有 a,ab。我们从好后缀的后缀子串中,找一个最长的并且能跟模式串的前缀子串匹配的,假设是{v},然后将模式串滑动到如图所示的位置。

坏字符和好后缀的基本原理都讲完了,我现在回答一下前面那个问题。当模式串和主串中的某个字符不匹配的时候,如何选择用好后缀规则还是坏字符规则,来计算模式串往后滑动的位数?

我们可以分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往后滑动的位数。这种处理方法还可以避免我们前面提到的,根据坏字符规则,计算得到的往后滑动的位数,有可能是负数的情况。

(太难了,看不下去了。。。)

BM 算法代码实现

BM 算法的性能分析及优化

解答开篇 & 内容小结

BM 算法核心思想是,利用模式串本身的特点,在模式串中某个字符与主串不能匹配的时候,将模式串往后多滑动几位,以此来减少不必要的字符比较,提高匹配的效率。BM 算法构建的规则有两类,坏字符规则和好后缀规则。好后缀规则可以独立于坏字符规则使用。因为坏字符规则的实现比较耗内存,为了节省内存,我们可以只用好后缀规则来实现 BM 算法。

2019/11/20 posted in  极客-数据结构与算法之美

34 | Graal:用Java编译Java

2019/12/14 posted in  极客-深入拆解Java虚拟机

34 | Worker Thread模式:如何避免重复创建线程?

2019/12/05 posted in  极客-Java并发实战

34 | 字符串匹配基础(下):如何借助BM算法轻松理解KMP算法?

  • KMP 算法基本原理
  • 失效函数计算方法
  • KMP 算法复杂度分析

(太难不看)

KMP 算法基本原理

失效函数计算方法

KMP 算法复杂度分析

解答开篇 & 内容小结

KMP 算法和上一节讲的 BM 算法的本质非常类似,都是根据规律在遇到坏字符的时候,把模式串往后多滑动几位。

BM 算法有两个规则,坏字符和好后缀。KMP 算法借鉴 BM 算法的思想,可以总结成好前缀规则。这里面最难懂的就是 next 数组的计算。如果用最笨的方法来计算,确实不难,但是效率会比较低。所以,我讲了一种类似动态规划的方法,按照下标 i 从小到大,依次计算 next[i],并且 next[i] 的计算通过前面已经计算出来的 next[0],next[1],……,next[i-1] 来推导。

KMP 算法的时间复杂度是 O(n+m),不过它的分析过程稍微需要一点技巧,不那么直观,你只要看懂就好了,并不需要掌握,在我们平常的开发中,很少会有这么难分析的代码。

2019/11/20 posted in  极客-数据结构与算法之美

35 | Trie树:如何实现搜索引擎的搜索关键词提示功能?

  • 什么是“Trie 树”?
  • 如何实现一棵 Trie 树?
  • Trie 树真的很耗内存吗?
  • Trie 树与散列表、红黑树的比较

什么是“Trie 树”?

Trie 树,也叫“字典树”。顾名思义,它是一个树形结构。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。

Trie 树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起。

其中,根节点不包含任何信息。每个节点表示一个字符串中的字符,从根节点到红色节点的一条路径表示一个字符串(注意:红色节点并不都是叶子节点)。


如何实现一棵 Trie 树?

从刚刚 Trie 树的介绍来看,Trie 树主要有两个操作:

  • 一个是将字符串集合构造成 Trie 树。这个过程分解开来的话,就是一个将字符串插入到 Trie 树的过程。
  • 另一个是在 Trie 树中查询一个字符串。

如何存储一个 Trie 树?

二叉树中,一个节点的左右子节点是通过两个指针来存储的,如下所示 Java 代码:

class BinaryTreeNode {
  char data;
  BinaryTreeNode left;
  BinaryTreeNode right;  
}

我先介绍其中一种存储方式,也是经典的存储方式——散列表。借助散列表的思想,我们通过一个下标与字符一一映射的数组,来存储子节点的指针。

当我们在 Trie 树中查找字符串的时候,我们就可以通过字符的 ASCII 码减去“a”的 ASCII 码,迅速找到匹配的子节点的指针。

public class Trie {
  private TrieNode root = new TrieNode('/'); // 存储无意义字符

  // 往Trie树中插入一个字符串
  public void insert(char[] text) {
    TrieNode p = root;
    for (int i = 0; i < text.length; ++i) {
      int index = text[i] - 'a';
      if (p.children[index] == null) {
        TrieNode newNode = new TrieNode(text[i]);
        p.children[index] = newNode;
      }
      p = p.children[index];
    }
    p.isEndingChar = true;
  }

  // 在Trie树中查找一个字符串
  public boolean find(char[] pattern) {
    TrieNode p = root;
    for (int i = 0; i < pattern.length; ++i) {
      int index = pattern[i] - 'a';
      if (p.children[index] == null) {
        return false; // 不存在pattern
      }
      p = p.children[index];
    }
    if (p.isEndingChar == false) return false; // 不能完全匹配,只是前缀
    else return true; // 找到pattern
  }

  public class TrieNode {
    public char data;
    public TrieNode[] children = new TrieNode[26];
    public boolean isEndingChar = false;
    public TrieNode(char data) {
      this.data = data;
    }
  }
}

在 Trie 树中,查找某个字符串的时间复杂度是多少?

  • 构建 Trie 树的过程,需要扫描所有的字符串,时间复杂度是 O(n)(n 表示所有字符串的长度和)。
  • 构建好 Trie 树后,在其中查找字符串的时间复杂度是 O(k),k 表示要查找的字符串的长度。

Trie 树真的很耗内存吗?

刚刚我们在讲 Trie 树的实现的时候,讲到用数组来存储一个节点的子节点的指针。如果字符串中包含从 a 到 z 这 26 个字符,那每个节点都要存储一个长度为 26 的数组,并且每个数组存储一个 8 字节指针(或者是 4 字节,这个大小跟 CPU、操作系统、编译器等有关)。而且,即便一个节点只有很少的子节点,远小于 26 个,比如 3、4 个,我们也要维护一个长度为 26 的数组。

我们可以稍微牺牲一点查询的效率,将每个节点中的数组换成其他数据结构,来存储一个节点的子节点指针。我们的选择其实有很多,比如有序数组、跳表、散列表、红黑树等。

实际上,Trie 树的变体有很多,都可以在一定程度上解决内存消耗的问题。比如,缩点优化,就是对只有一个子节点的节点,而且此节点不是一个串的结束节点,可以将此节点与子节点合并。这样可以节省空间,但却增加了编码难度。

Trie 树与散列表、红黑树的比较

在一组字符串中查找字符串,Trie 树实际上表现得并不好。它对要处理的字符串有及其严苛的要求。

  • 第一,字符串中包含的字符集不能太大。我们前面讲到,如果字符集太大,那存储空间可能就会浪费很多。即便可以优化,但也要付出牺牲查询、插入效率的代价。
  • 第二,要求字符串的前缀重合比较多,不然空间消耗会变大很多。
  • 第三,如果要用 Trie 树解决问题,那我们就要自己从零开始实现一个 Trie 树,还要保证没有 bug,这个在工程上是将简单问题复杂化,除非必须,一般不建议这样做。
  • 第四,我们知道,通过指针串起来的数据块是不连续的,而 Trie 树中用到了指针,所以,对缓存并不友好,性能上会打个折扣。

针对在一组字符串中查找字符串的问题,我们在工程中,更倾向于用散列表或者红黑树。Trie 树只是不适合精确匹配查找,这种问题更适合用散列表或者红黑树来解决。Trie 树比较适合的是查找前缀匹配的字符串。

解答开篇

如何利用 Trie 树,实现搜索关键词的提示功能?

我们假设关键词库由用户的热门搜索关键词组成。我们将这个词库构建成一个 Trie 树。当用户输入其中某个单词的时候,把这个词作为一个前缀子串在 Trie 树中匹配。为了讲解方便,我们假设词库里只有 hello、her、hi、how、so、see 这 6 个关键词。当用户输入了字母 h 的时候,我们就把以 h 为前缀的 hello、her、hi、how 展示在搜索提示框内。当用户继续键入字母 e 的时候,我们就把以 he 为前缀的 hello、her 展示在搜索提示框内。这就是搜索关键词提示的最基本的算法原理。

Trie 树的这个应用可以扩展到更加广泛的一个应用上,就是自动输入补全,比如输入法自动补全功能、IDE 代码编辑器自动补全功能、浏览器网址输入的自动补全功能等等。

内容小结

rie 树是一种解决字符串快速匹配问题的数据结构。如果用来构建 Trie 树的这一组字符串中,前缀重复的情况不是很多,那 Trie 树这种数据结构总体上来讲是比较费内存的,是一种空间换时间的解决问题思路。

尽管比较耗费内存,但是对内存不敏感或者内存消耗在接受范围内的情况下,在 Trie 树中做字符串匹配还是非常高效的,时间复杂度是 O(k),k 表示要匹配的字符串的长度。

但是,Trie 树的优势并不在于,用它来做动态集合数据的查找,因为,这个工作完全可以用更加合适的散列表或者红黑树来替代。Trie 树最有优势的是查找前缀匹配的字符串,比如搜索引擎中的关键词提示功能这个场景,就比较适合用它来解决,也是 Trie 树比较经典的应用场景。

2019/11/20 posted in  极客-数据结构与算法之美

35 | Truffle:语言实现框架

2019/12/14 posted in  极客-深入拆解Java虚拟机

35 | 两阶段终止模式:如何优雅地终止线程?

2019/12/05 posted in  极客-Java并发实战

36 | AC自动机:如何用多模式串匹配实现敏感词过滤功能?

  • 基于单模式串和 Trie 树实现的敏感词过滤
  • 经典的多模式串匹配算法:AC 自动机

基于单模式串和 Trie 树实现的敏感词过滤

经典的多模式串匹配算法:AC 自动机

解答开篇

内容小结

今天我们讲了多模式串匹配算法,AC 自动机。单模式串匹配算法是为了快速在主串中查找一个模式串,而多模式串匹配算法是为了快速在主串中查找多个模式串。

AC 自动机是基于 Trie 树的一种改进算法,它跟 Trie 树的关系,就像单模式串中,KMP 算法与 BF 算法的关系一样。KMP 算法中有一个非常关键的 next 数组,类比到 AC 自动机中就是失败指针。而且,AC 自动机失败指针的构建过程,跟 KMP 算法中计算 next 数组极其相似。所以,要理解 AC 自动机,最好先掌握 KMP 算法,因为 AC 自动机其实就是 KMP 算法在多模式串上的改造。

整个 AC 自动机算法包含两个部分,第一部分是将多个模式串构建成 AC 自动机,第二部分是在 AC 自动机中匹配主串。第一部分又分为两个小的步骤,一个是将模式串构建成 Trie 树,另一个是在 Trie 树上构建失败指针。

2019/11/20 posted in  极客-数据结构与算法之美

36 | SubstrateVM:AOT编译框架

2019/12/14 posted in  极客-深入拆解Java虚拟机

36 | 生产者-消费者模式:用流水线思想提高效率

2019/12/05 posted in  极客-Java并发实战

37 | 设计模式模块热点问题答疑

2019/12/05 posted in  极客-Java并发实战

37 | 贪心算法:如何用贪心算法实现Huffman压缩编码?

  • 如何理解“贪心算法”?
  • 贪心算法实战分析
    1. 分糖果
    2. 钱币找零
    3. 区间覆盖

我们今天讲下霍夫曼编码,看看它是如何利用贪心算法来实现对数据压缩编码,有效节省数据存储空间的

如何理解“贪心算法”?

第一步,当我们看到这类问题的时候,首先要联想到贪心算法:针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大。

第二步,我们尝试看下这个问题是否可以用贪心算法解决:每次选择当前情况下,在对限制值同等贡献量的情况下,对期望值贡献最大的数据。

第三步,我们举几个例子看下贪心算法产生的结果是否是最优的。大部分情况下,举几个例子验证一下就可以了。

贪心算法实战分析

1. 分糖果

我们有 m 个糖果和 n 个孩子。我们现在要把糖果分给这些孩子吃,但是糖果少,孩子多(m<n),所以糖果只能分配给一部分孩子。每个糖果的大小不等,每个孩子对糖果大小的需求也是不一样的,只有糖果的大小大于等于孩子的对糖果大小的需求的时候,孩子才得到满足。

如何分配糖果,能尽可能满足最多数量的孩子?

  1. 我们可以从需求小的孩子开始分配糖果。因为满足一个需求大的孩子跟满足一个需求小的孩子,对我们期望值的贡献是一样的。
  2. 我们每次从剩下的孩子中,找出对糖果大小需求最小的,然后发给他剩下的糖果中能满足他的最小的糖果,这样得到的分配方案,也就是满足的孩子个数最多的方案。

2. 钱币找零

假设我们有 1 元、2 元、5 元、10 元、20 元、50 元、100 元这些面额的纸币,它们的张数不等。我们现在要用这些钱来支付 K 元,最少要用多少张纸币呢?

在生活中,我们肯定是先用面值最大的来支付,如果不够,就继续用更小一点面值的,以此类推,最后剩下的用 1 元来补齐。在贡献相同期望值(纸币数目)的情况下,我们希望多贡献点金额,这样就可以让纸币数更少,这就是一种贪心算法的解决思路。

3. 区间覆盖

假设我们有 n 个区间,区间的起始端点和结束端点分别是 [l1, r1],[l2, r2],[l3, r3],……,[ln, rn]。我们从这 n 个区间中选出一部分区间,这部分区间满足两两不相交(端点相交的情况不算相交),最多能选出多少个区间呢?

这个问题的处理思路在很多贪心算法问题中都有用到,比如任务调度、教师排课等等问题。

这个问题的解决思路是这样的:

  1. 我们假设这 n 个区间中最左端点是 lmin,最右端点是 rmax。这个问题就相当于,我们选择几个不相交的区间,从左到右将 [lmin, rmax] 覆盖上。我们按照起始端点从小到大的顺序对这 n 个区间排序。
  2. 我们每次选择的时候,左端点跟前面的已经覆盖的区间不重合的,右端点又尽量小的,这样可以让剩下的未覆盖区间尽可能的大,就可以放置更多的区间。这实际上就是一种贪心的选择方法。

解答开篇

如何用贪心算法实现霍夫曼编码?

假设我有一个包含 1000 个字符的文件,每个字符占 1 个 byte(1byte=8bits),存储这 1000 个字符就一共需要 8000bits,那有没有更加节省空间的存储方式呢?

假设我们通过统计分析发现,这 1000 个字符中只包含 6 种不同字符,假设它们分别是 a、b、c、d、e、f。而 3 个二进制位(bit)就可以表示 8 个不同的字符,所以,为了尽量减少存储空间,每个字符我们用 3 个二进制位来表示。那存储这 1000 个字符只需要 3000bits 就可以了,比原来的存储方式节省了很多空间。不过,还有没有更加节省空间的存储方式呢?

霍夫曼编码不仅会考察文本中有多少个不同字符,还会考察每个字符出现的频率,根据频率的不同,选择不同长度的编码。霍夫曼编码试图用这种不等长的编码方法,来进一步增加压缩的效率。如何给不同频率的字符选择不同长度的编码呢?根据贪心的思想,我们可以把出现频率比较多的字符,用稍微短一些的编码;出现频率比较少的字符,用稍微长一些的编码

假设这 6 个字符出现的频率从高到低依次是 a、b、c、d、e、f。我们把它们编码下面这个样子,任何一个字符的编码都不是另一个的前缀,在解压缩的时候,我们每次会读取尽可能长的可解压的二进制串,所以在解压缩的时候也不会歧义。经过这种编码压缩之后,这 1000 个字符只需要 2100bits 就可以了。

但是如何根据字符出现频率的不同,给不同的字符进行不同长度的编码呢?

我们把每个字符看作一个节点,并且辅带着把频率放到优先级队列中。我们从队列中取出频率最小的两个节点 A、B,然后新建一个节点 C,把频率设置为两个节点的频率之和,并把这个新节点 C 作为节点 A、B 的父节点。最后再把 C 节点放入到优先级队列中。重复这个过程,直到队列中没有数据。

现在,我们给每一条边加上画一个权值,指向左子节点的边我们统统标记为 0,指向右子节点的边,我们统统标记为 1,那从根节点到叶节点的路径就是叶节点对应字符的霍夫曼编码。

内容小结

贪心算法适用的场景比较有限。这种算法思想更多的是指导设计基础算法。比如最小生成树算法、单源最短路径算法,这些算法都用到了贪心算法。贪心算法的最难的一块是如何将要解决的问题抽象成贪心算法模型,只要这一步搞定之后,贪心算法的编码一般都很简单。

课后思考

问:在一个非负整数 a 中,我们希望从中移除 k 个数字,让剩下的数字值最小,如何选择移除哪 k 个数字呢?

答:从高位开始移除:移除高位数字比它低位数字大的那个;K 次循环。

问:假设有 n 个人等待被服务,但是服务窗口只有一个,每个人需要被服务的时间长度是不同的,如何安排被服务的先后顺序,才能让这 n 个人总的等待时间最短?

由等待时间最短的开始服务

2019/11/20 posted in  极客-数据结构与算法之美

37 尾声 | 道阻且长,努力加餐

2019/12/14 posted in  极客-深入拆解Java虚拟机

38 | 分治算法:谈一谈大规模计算框架MapReduce中的分治思想

  • 如何理解分治算法?
  • 分治算法应用举例分析
  • 分治思想在海量数据处理中的应用

如何理解分治算法?

分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成 n 个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。

分治算法是一种处理问题的思想,递归是一种编程技巧。分治算法一般都比较适合用递归来实现。

分治算法的递归实现中,每一层递归都会涉及这样三个操作:

  1. 分解:将原问题分解成一系列子问题;
  2. 解决:递归地求解各个子问题,若子问题足够小,则直接求解;
  3. 合并:将子问题的结果合并成原问题。

分治算法能解决的问题,一般需要满足下面这几个条件:

  • 原问题与分解成的小问题具有相同的模式;
  • 原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别;
  • 具有分解终止条件,也就是说,当问题足够小时,可以直接求解;
  • 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。

分治算法应用举例分析

假设我们有 n 个数据,我们期望数据从小到大排列,那完全有序的数据的有序度就是 n(n-1)/2,逆序度等于 0;相反,倒序排列的数据的有序度就是 0,逆序度是 n(n-1)/2。除了这两种极端情况外,我们通过计算有序对或者逆序对的个数,来表示数据的有序度或逆序度。

如何编程求出一组数据的有序对个数或者逆序对个数呢?

我们套用分治的思想来求数组 A 的逆序对个数。我们可以将数组分成前后两半 A1 和 A2,分别计算 A1 和 A2 的逆序对个数 K1 和 K2,然后再计算 A1 与 A2 之间的逆序对个数 K3。那数组 A 的逆序对个数就等于 K1+K2+K3。
如何快速计算出两个子问题 A1 与 A2 之间的逆序对个数呢?
这里就要借助归并排序算法了。归并排序中有一个非常关键的操作,就是将两个有序的小数组,合并成一个有序的数组。实际上,在这个合并的过程中,我们就可以计算这两个小数组的逆序对个数了。每次合并操作,我们都计算逆序对个数,把这些计算出来的逆序对个数求和,就是这个数组的逆序对个数了。

关于分治算法,我这还有两道比较经典的问题,你可以自己练习一下。

  • 二维平面上有 n 个点,如何快速计算出两个距离最近的点对?
  • 有两个 n*n 的矩阵 A,B,如何快速求解两个矩阵的乘积 C=A*B?

分治思想在海量数据处理中的应用

分治算法思想的应用是非常广泛的,并不仅限于指导编程和算法设计。它还经常用在海量数据处理的场景中。比如,给 10GB 的订单文件按照金额排序这样一个需求,看似是一个简单的排序问题,但是因为数据量大,有 10GB,而我们的机器的内存可能只有 2、3GB 这样子,无法一次性加载到内存,也就无法通过单纯地使用快排、归并等基础算法来解决了。

要解决这种数据量大到内存装不下的问题,我们就可以利用分治的思想。我们可以将海量的数据集合根据某种方法,划分为几个小的数据集合,每个小的数据集合单独加载到内存来解决,然后再将小数据集合合并成大数据集合。实际上,利用这种分治的处理思路,不仅仅能克服内存的限制,还能利用多线程或者多机处理,加快处理的速度。

比如刚刚举的那个例子,给 10GB 的订单排序,我们就可以先扫描一遍订单,根据订单的金额,将 10GB 的文件划分为几个金额区间。比如订单金额为 1 到 100 元的放到一个小文件,101 到 200 之间的放到另一个文件,以此类推。这样每个小文件都可以单独加载到内存排序,最后将这些有序的小文件合并,就是最终有序的 10GB 订单数据了。

解答开篇

为什么说 MapReduce 的本质就是分治思想?

实际上,MapReduce 框架只是一个任务调度器,底层依赖 GFS 来存储数据,依赖 Borg 管理机器。它从 GFS 中拿数据,交给 Borg 中的机器执行,并且时刻监控机器执行的进度,一旦出现机器宕机、进度卡壳等,就重新从 Borg 中调度一台机器执行。

内容小结

今天我们讲了一种应用非常广泛的算法思想,分治算法。

分治算法用四个字概括就是“分而治之”,将原问题划分成 n 个规模较小而结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。这个思想非常简单、好理解。

今天我们讲了两种分治算法的典型的应用场景,一个是用来指导编码,降低问题求解的时间复杂度,另一个是解决海量数据处理问题。比如 MapReduce 本质上就是利用了分治思想。

我们也时常感叹 Google 的创新能力如此之强,总是在引领技术的发展。实际上,创新并非离我们很远,创新的源泉来自对事物本质的认识。无数优秀架构设计的思想来源都是基础的数据结构和算法,这本身就是算法的一个魅力所在。

2019/11/20 posted in  极客-数据结构与算法之美

39 | 回溯算法:从电影《蝴蝶效应》中学习回溯算法的核心思想

  • 如何理解“回溯算法”?
  • 两个回溯算法的经典应用
    1. 0-1 背包
    2. 正则表达式

我们在第 31 节提到,深度优先搜索算法利用的是回溯算法思想。回溯算法除了用来指导像深度优先搜索这种经典的算法设计之外,还可以用在很多实际的软件开发场景中,比如正则表达式匹配、编译原理中的语法分析等。除此之外,很多经典的数学问题都可以用回溯算法解决,比如数独、八皇后、0-1 背包、图的着色、旅行商问题、全排列等等。

如何理解“回溯算法”?

笼统地讲,回溯算法很多时候都应用在“搜索”这类问题上。不过这里说的搜索,并不是狭义的指我们前面讲过的图的搜索算法,而是在一组可能的解中,搜索满足期望的解。

回溯的处理思想,有点类似枚举搜索。我们枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。

我举一个经典的回溯例子,我想你可能已经猜到了,那就是八皇后问题。我们有一个 8x8 的棋盘,希望往里放 8 个棋子(皇后),每个棋子所在的行、列、对角线都不能有另一个棋子。你可以看我画的图,第一幅图是满足条件的一种方法,第二幅图是不满足条件的。八皇后问题就是期望找到所有满足这种要求的放棋子方式。

我们把这个问题划分成 8 个阶段,依次将 8 个棋子放到第一行、第二行、第三行……第八行。在放置的过程中,我们不停地检查当前的方法,是否满足要求。如果满足,则跳到下一行继续放置棋子;如果不满足,那就再换一种方法,继续尝试。

回溯算法非常适合用递归代码实现。

两个回溯算法的经典应用

1. 0-1 背包
我们有一个背包,背包总的承载重量是 Wkg。现在我们有 n 个物品,每个物品的重量不等,并且不可分割。我们现在期望选择几件物品,装载到背包中。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?

对于每个物品来说,都有两种选择,装进背包或者不装进背包。对于 n 个物品来说,总的装法就有 2n 种,去掉总重量超过 Wkg 的,从剩下的装法中选择总重量最接近 Wkg 的。不过,我们如何才能不重复地穷举出这 2n 种装法呢?

这里就可以用回溯的方法。我们可以把物品依次排列,整个问题就分解为了 n 个阶段,每个阶段对应一个物品怎么选择。先对第一个物品进行处理,选择装进去或者不装进去,然后再递归地处理剩下的物品。

这里还稍微用到了一点搜索剪枝的技巧,就是当发现已经选择的物品的重量超过 Wkg 之后,我们就停止继续探测剩下的物品。

public int maxW = Integer.MIN_VALUE; //存储背包中物品总重量的最大值
// cw表示当前已经装进去的物品的重量和;i表示考察到哪个物品了;
// w背包重量;items表示每个物品的重量;n表示物品个数
// 假设背包可承受重量100,物品个数10,物品重量存储在数组a中,那可以这样调用函数:
// f(0, 0, a, 10, 100)
public void f(int i, int cw, int[] items, int n, int w) {
  if (cw == w || i == n) { // cw==w表示装满了;i==n表示已经考察完所有的物品
    if (cw > maxW) maxW = cw;
    return;
  }
  f(i+1, cw, items, n, w);
  if (cw + items[i] <= w) {// 已经超过可以背包承受的重量的时候,就不要再装了
    f(i+1,cw + items[i], items, n, w);
  }
}

2. 正则表达式

正则表达式中,最重要的就是通配符,通配符结合在一起,可以表达非常丰富的语义。为了方便讲解,我假设正则表达式中只包含“*”和“?”这两种通配符,并且对这两个通配符的语义稍微做些改变,其中,“*”匹配任意多个(大于等于 0 个)任意字符,“?”匹配零个或者一个任意字符。基于以上背景假设,我们看下,如何用回溯算法,判断一个给定的文本,能否跟给定的正则表达式匹配?

我们依次考察正则表达式中的每个字符,当是非通配符时,我们就直接跟文本的字符进行匹配,如果相同,则继续往下处理;如果不同,则回溯。

如果遇到特殊字符的时候,我们就有多种处理方式了,也就是所谓的岔路口,比如“*”有多种匹配方案,可以匹配任意个文本串中的字符,我们就先随意的选择一种匹配方案,然后继续考察剩下的字符。如果中途发现无法继续匹配下去了,我们就回到这个岔路口,重新选择一种匹配方案,然后再继续匹配剩下的字符。

内容小结

回溯算法的思想非常简单,大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。利用剪枝,我们并不需要穷举搜索所有的情况,从而提高搜索效率。

尽管回溯算法的原理非常简单,但是却可以解决很多问题,比如我们开头提到的深度优先搜索、八皇后、0-1 背包问题、图的着色、旅行商问题、数独、全排列、正则表达式匹配等等。

课后思考

现在我们对今天讲到的 0-1 背包问题稍加改造,如果每个物品不仅重量不同,价值也不同。如何在不超过背包重量的情况下,让背包中的总价值最大?

2019/11/20 posted in  极客-数据结构与算法之美

40 | 初识动态规划:如何巧妙解决“双十一”购物时的凑单问题?

  • 动态规划学习路线
  • 0-1 背包问题
  • 0-1 背包问题升级版

动态规划学习路线

动态规划比较适合用来求解最优问题,比如求最大值、最小值等等。它可以非常显著地降低时间复杂度,提高代码的执行效率。

为了让你更容易理解动态规划,我分了三节给你讲解。这三节分别是,初识动态规划、动态规划理论、动态规划实战。

第一节,我会通过两个非常经典的动态规划问题模型,向你展示我们为什么需要动态规划,以及动态规划解题方法是如何演化出来的。实际上,你只要掌握了这两个例子的解决思路,对于其他很多动态规划问题,你都可以套用类似的思路来解决。

第二节,我会总结动态规划适合解决的问题的特征,以及动态规划解题思路。除此之外,我还会将贪心、分治、回溯、动态规划这四种算法思想放在一起,对比分析它们各自的特点以及适用的场景。

第三节,我会教你应用第二节讲的动态规划理论知识,实战解决三个非常经典的动态规划问题,加深你对理论的理解。

0-1 背包问题

对于一组不同重量、不可分割的物品,我们需要选择一些装入背包,在满足背包最大重量限制的前提下,背包中物品总重量的最大值是多少呢?

我们假设背包的最大承载重量是 9。我们有 5 个不同的物品,每个物品的重量分别是 2,2,4,6,3。如果我们把这个例子的回溯求解过程,用递归树画出来,就是下面这个样子:

递归树中的每个节点表示一种状态,我们用(i, cw)来表示。其中,i 表示将要决策第几个物品是否装入背包,cw 表示当前背包中物品的总重量。比如,(2,2)表示我们将要决策第 2 个物品是否装入背包,在决策前,背包中物品的总重量是 2。

从递归树中,你应该能会发现,有些子问题的求解是重复的,比如图中 f(2, 2) 和 f(3,4) 都被重复计算了两次。我们可以借助递归那一节讲的“备忘录”的解决方式,记录已经计算好的 f(i, cw),当再次计算到重复的 f(i, cw) 的时候,可以直接从备忘录中取出来用,就不用再递归计算了,这样就可以避免冗余计算。

private int maxW = Integer.MIN_VALUE; // 结果放到maxW中
private int[] weight = {2,2,4,6,3};  // 物品重量
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
private boolean[][] mem = new boolean[5][10]; // 备忘录,默认值false
public void f(int i, int cw) { // 调用f(0, 0)
  if (cw == w || i == n) { // cw==w表示装满了,i==n表示物品都考察完了
    if (cw > maxW) maxW = cw;
    return;
  }
  if (mem[i][cw]) return; // 重复状态
  mem[i][cw] = true; // 记录(i, cw)这个状态
  f(i+1, cw); // 选择不装第i个物品
  if (cw + weight[i] <= w) {
    f(i+1,cw + weight[i]); // 选择装第i个物品
  }
}

这种解决方法非常好。实际上,它已经跟动态规划的执行效率基本上没有差别。但是,多一种方法就多一种解决思路,我们现在来看看动态规划是怎么做的。

我们把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个物品决策(放入或者不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同的状态,对应到递归树中,就是有很多不同的节点。

我们把每一层重复的状态(节点)合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。我们可以通过合并每一层重复的状态,这样就保证每一层不同状态的个数都不会超过 w 个(w 表示背包的承载重量),也就是例子中的 9。于是,我们就成功避免了每层状态个数的指数级增长。

我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。

第 0 个(下标从 0 开始编号)物品的重量是 2,要么装入背包,要么不装入背包,决策完之后,会对应背包的两种状态,背包中物品的总重量是 0 或者 2。我们用 states[0][0]=true 和 states[0][2]=true 来表示这两种状态。

第 1 个物品的重量也是 2,基于之前的背包状态,在这个物品决策完之后,不同的状态有 3 个,背包中物品总重量分别是 0(0+0),2(0+2 or 2+0),4(2+2)。我们用 states[1][0]=true,states[1][2]=true,states[1][4]=true 来表示这三种状态。

以此类推,直到考察完所有的物品后,整个 states 状态数组就都计算好了。我把整个计算的过程画了出来,你可以看看。图中 0 表示 false,1 表示 true。我们只需要在最后一层,找一个值为 true 的最接近 w(这里是 9)的值,就是背包中物品总重量的最大值。


weight:物品重量,n:物品个数,w:背包可承载重量
public int knapsack(int[] weight, int n, int w) {
  boolean[][] states = new boolean[n][w+1]; // 默认值false
  states[0][0] = true;  // 第一行的数据要特殊处理,可以利用哨兵优化
  if (weight[0] <= w) {
    states[0][weight[0]] = true;
  }
  for (int i = 1; i < n; ++i) { // 动态规划状态转移
    for (int j = 0; j <= w; ++j) {// 不把第i个物品放入背包
      if (states[i-1][j] == true) states[i][j] = states[i-1][j];
    }
    for (int j = 0; j <= w-weight[i]; ++j) {//把第i个物品放入背包
      if (states[i-1][j]==true) states[i][j+weight[i]] = true;
    }
  }
  for (int i = w; i >= 0; --i) { // 输出结果
    if (states[n-1][i] == true) return i;
  }
  return 0;
}

实际上,这就是一种用动态规划解决问题的思路。我们把问题分解为多个阶段,每个阶段对应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。

那动态规划解决方案的时间复杂度是多少呢?

这个代码的时间复杂度非常好分析,耗时最多的部分就是代码中的两层 for 循环,所以时间复杂度是 O(n*w)。n 表示物品个数,w 表示背包可以承载的总重量。

0-1 背包问题升级版

我们现在引入物品价值这一变量。对于一组不同重量、不同价值、不可分割的物品,我们选择将某些物品装入背包,在满足背包最大重量限制的前提下,背包中可装入物品的总价值最大是多少呢?

这个问题依旧可以用回溯算法来解决。在递归树中,每个节点表示一个状态。现在我们需要 3 个变量(i, cw, cv)来表示一个状态。其中,i 表示即将要决策第 i 个物品是否装入背包,cw 表示当前背包中物品的总重量,cv 表示当前背包中物品的总价值。

我们发现,在递归树中,有几个节点的 i 和 cw 是完全相同的,比如 f(2,2,4) 和 f(2,2,3)。在背包中物品总重量一样的情况下,f(2,2,4) 这种状态对应的物品总价值更大,我们可以舍弃 f(2,2,3) 这种状态,只需要沿着 f(2,2,4) 这条决策路线继续往下决策就可以。

也就是说,对于 (i, cw) 相同的不同状态,那我们只需要保留 cv 值最大的那个,继续递归处理,其他状态不予考虑。

如果用回溯算法,这个问题就没法再用“备忘录”解决了。所以,我们就需要换一种思路,看看动态规划是不是更容易解决这个问题?

我们还是把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个阶段决策完之后,背包中的物品的总重量以及总价值,会有多种情况,也就是会达到多种不同的状态。

我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。不过这里数组存储的值不再是 boolean 类型的了,而是当前状态对应的最大总价值。我们把每一层中 (i, cw) 重复的状态(节点)合并,只记录 cv 值最大的那个状态,然后基于这些状态来推导下一层的状态。

public static int knapsack3(int[] weight, int[] value, int n, int w) {
  int[][] states = new int[n][w+1];
  for (int i = 0; i < n; ++i) { // 初始化states
    for (int j = 0; j < w+1; ++j) {
      states[i][j] = -1;
    }
  }
  states[0][0] = 0;
  if (weight[0] <= w) {
    states[0][weight[0]] = value[0];
  }
  for (int i = 1; i < n; ++i) { //动态规划,状态转移
    for (int j = 0; j <= w; ++j) { // 不选择第i个物品
      if (states[i-1][j] >= 0) states[i][j] = states[i-1][j];
    }
    for (int j = 0; j <= w-weight[i]; ++j) { // 选择第i个物品
      if (states[i-1][j] >= 0) {
        int v = states[i-1][j] + value[i];
        if (v > states[i][j+weight[i]]) {
          states[i][j+weight[i]] = v;
        }
      }
    }
  }
  // 找出最大值
  int maxvalue = -1;
  for (int j = 0; j <= w; ++j) {
    if (states[n-1][j] > maxvalue) maxvalue = states[n-1][j];
  }
  return maxvalue;
}

时间复杂度是 O(n*w),空间复杂度也是 O(n*w)。

课后思考

“杨辉三角”不知道你听说过吗?我们现在对它进行一些改造。每个位置的数字可以随意填写,经过某个数字只能到达下面一层相邻的两个数字。

假设你站在第一层,往下移动,我们把移动到最底层所经过的所有数字之和,定义为路径的长度。请你编程求出从最高层移动到最底层的最短路径长度。

2019/11/20 posted in  极客-数据结构与算法之美

41 | 动态规划理论:一篇文章带你彻底搞懂最优子结构、无后效性和重复子问题

  • “一个模型三个特征”理论讲解
    1. 最优子结构
    2. 无后效性
    3. 3. 重复子问题
  • “一个模型三个特征”实例剖析
  • 两种动态规划解题思路总结
    1. 状态转移表法
    2. 状态转移方程法
  • 四种算法思想比较分析

什么样的问题可以用动态规划解决?解决动态规划问题的一般思考过程是什么样的?贪心、分治、回溯、动态规划这四种算法思想又有什么区别和联系?

“一个模型三个特征”理论讲解

一个模型”?它指的是动态规划适合解决的问题的模型。我把这个模型定义为“多阶段决策最优解模型”。

我们一般是用动态规划来解决最优问题。而解决问题的过程,需要经历多个决策阶段。每个决策阶段都对应着一组状态。然后我们寻找一组决策序列,经过这组决策序列,能够产生最终期望求解的最优值。

什么是“三个特征”?它们分别是最优子结构、无后效性和重复子问题。

1. 最优子结构

最优子结构指的是,问题的最优解包含子问题的最优解。反过来说就是,我们可以通过子问题的最优解,推导出问题的最优解。如果我们把最优子结构,对应到我们前面定义的动态规划问题模型上,那我们也可以理解为,后面阶段的状态可以通过前面阶段的状态推导出来。

2. 无后效性

无后效性有两层含义,第一层含义是,在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。第二层含义是,某阶段状态一旦确定,就不受之后阶段的决策影响。无后效性是一个非常“宽松”的要求。只要满足前面提到的动态规划问题模型,其实基本上都会满足无后效性。

3. 重复子问题

不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态。

“一个模型三个特征”实例剖析

假设我们有一个 n 乘以 n 的矩阵 w[n][n]。矩阵存储的都是正整数。棋子起始位置在左上角,终止位置在右下角。我们将棋子从左上角移动到右下角。每次只能向右或者向下移动一位。从左上角到右下角,会有很多不同的路径可以走。我们把每条路径经过的数字加起来看作路径的长度。那从左上角移动到右下角的最短路径长度是多少呢?

两种动态规划解题思路总结

1. 状态转移表法

一般能用动态规划解决的问题,都可以使用回溯算法的暴力搜索解决。所以,当我们拿到问题的时候,我们可以先用简单的回溯算法解决,然后定义状态,每个状态表示一个节点,然后对应画出递归树。从递归树中,我们很容易可以看出来,是否存在重复子问题,以及重复子问题是如何产生的。以此来寻找规律,看是否能用动态规划解决。

找到重复子问题之后,接下来,我们有两种处理思路,第一种是直接用回溯加“备忘录”的方法,来避免重复子问题。从执行效率上来讲,这跟动态规划的解决思路没有差别。第二种是使用动态规划的解决方法,状态转移表法。我们先画出一个状态表。状态表一般都是二维的,所以你可以把它想象成二维数组。其中,每个状态包含三个变量,行、列、数组值。我们根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态。最后,我们将这个递推填表的过程,翻译成代码,就是动态规划代码了。

尽管大部分状态表都是二维的,但是如果问题的状态比较复杂,需要很多变量来表示,那对应的状态表可能就是高维的,比如三维、四维。那这个时候,我们就不适合用状态转移表法来解决了。

2. 状态转移方程法

状态转移方程法有点类似递归的解题思路。我们需要分析,某个问题如何通过子问题来递归求解,也就是所谓的最优子结构。根据最优子结构,写出递归公式,也就是所谓的状态转移方程。有了状态转移方程,代码实现就非常简单了。一般情况下,我们有两种代码实现方法,一种是递归加“备忘录”,另一种是迭代递推

状态转移方程是解决动态规划的关键。如果我们能写出状态转移方程,那动态规划问题基本上就解决一大半了,而翻译成代码非常简单。但是很多动态规划问题的状态本身就不好定义,状态转移方程也就更不好想到。

四种算法思想比较分析

如果我们将这四种算法思想分一下类,那贪心、回溯、动态规划可以归为一类,而分治单独可以作为一类,因为它跟其他三个都不大一样。为什么这么说呢?前三个算法解决问题的模型,都可以抽象成我们今天讲的那个多阶段决策最优解模型,而分治算法解决的问题尽管大部分也是最优解问题,但是,大部分都不能抽象成多阶段决策模型。

回溯算法是个“万金油”。基本上能用的动态规划、贪心解决的问题,我们都可以用回溯算法解决。回溯算法相当于穷举搜索。穷举所有的情况,然后对比得到最优解。不过,回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了。

尽管动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决。能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题。在重复子问题这一点上,动态规划和分治算法的区分非常明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题。

贪心算法实际上是动态规划算法的一种特殊情况。它解决问题起来更加高效,代码实现也更加简洁。不过,它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性(这里我们不怎么强调重复子问题)。

其中,最优子结构、无后效性跟动态规划中的无异。“贪心选择性”的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,我们都选择当前看起来最优的决策,所有阶段的决策完成之后,最终由这些局部最优解构成全局最优解。内容小结

内容小结

我首先讲了什么样的问题适合用动态规划解决。这些问题可以总结概括为“一个模型三个特征”。其中,“一个模型”指的是,问题可以抽象成分阶段决策最优解模型。“三个特征”指的是最优子节、无后效性和重复子问题。

然后,我讲了两种动态规划的解题思路。它们分别是状态转移表法和状态转移方程法。其中,状态转移表法解题思路大致可以概括为,回溯算法实现 - 定义状态 - 画递归树 - 找重复子问题 - 画状态转移表 - 根据递推关系填表 - 将填表过程翻译成代码。状态转移方程法的大致思路可以概括为,找最优子结构 - 写状态转移方程 - 将状态转移方程翻译成代码

最后,我们对比了之前讲过的四种算法思想。贪心、回溯、动态规划可以解决的问题模型类似,都可以抽象成多阶段决策最优解模型。尽管分治算法也能解决最优问题,但是大部分问题的背景都不适合抽象成多阶段决策模型。

2019/11/20 posted in  极客-数据结构与算法之美

42 | 动态规划实战:如何实现搜索引擎中的拼写纠错功能?

  • 如何量化两个字符串的相似度?
  • 如何编程计算莱文斯坦距离?
  • 如何编程计算最长公共子串长度?

如何量化两个字符串的相似度?

如何量化两个字符串之间的相似程度呢?有一个非常著名的量化方法,那就是编辑距离(Edit Distance)。编辑距离指的就是,将一个字符串转化成另一个字符串,需要的最少编辑操作次数(比如增加一个字符、删除一个字符、替换一个字符)。编辑距离越大,说明两个字符串的相似程度越小;相反,编辑距离就越小,说明两个字符串的相似程度越大。对于两个完全相同的字符串来说,编辑距离就是 0。

根据所包含的编辑操作种类的不同,编辑距离有多种不同的计算方式,比较著名的有莱文斯坦距离(Levenshtein distance)和最长公共子串长度(Longest common substring length)。其中,莱文斯坦距离允许增加、删除、替换字符这三个编辑操作,最长公共子串长度只允许增加、删除字符这两个编辑操作。

而且,莱文斯坦距离和最长公共子串长度,从两个截然相反的角度,分析字符串的相似程度。莱文斯坦距离的大小,表示两个字符串差异的大小;而最长公共子串的大小,表示两个字符串相似程度的大小。

关于这两个计算方法,我举个例子给你说明一下。这里面,两个字符串 mitcmu 和 mtacnu 的莱文斯坦距离是 3,最长公共子串长度是 4。

如何编程计算莱文斯坦距离?

这个问题是求把一个字符串变成另一个字符串,需要的最少编辑次数。整个求解过程,涉及多个决策阶段,我们需要依次考察一个字符串中的每个字符,跟另一个字符串中的字符是否匹配,匹配的话如何处理,不匹配的话又如何处理。所以,这个问题符合多阶段决策最优解模型

我们前面讲了,贪心、回溯、动态规划可以解决的问题,都可以抽象成这样一个模型。要解决这个问题,我们可以先看一看,用最简单的回溯算法,该如何来解决。回溯是一个递归处理的过程。如果 a[i] 与 b[j] 匹配,我们递归考察 a[i+1] 和 b[j+1]。如果 a[i] 与 b[j] 不匹配,那我们有多种处理方式可选:

  • 可以删除 a[i],然后递归考察 a[i+1] 和 b[j];
  • 可以删除 b[j],然后递归考察 a[i] 和 b[j+1];
  • 可以在 a[i] 前面添加一个跟 b[j] 相同的字符,然后递归考察 a[i] 和 b[j+1];
  • 可以在 b[j] 前面添加一个跟 a[i] 相同的字符,然后递归考察 a[i+1] 和 b[j];
  • 可以将 a[i] 替换成 b[j],或者将 b[j] 替换成 a[i],然后递归考察 a[i+1] 和 b[j+1]。

根据回溯算法的代码实现,我们可以画出递归树,看是否存在重复子问题。如果存在重复子问题,那我们就可以考虑能否用动态规划来解决;如果不存在重复子问题,那回溯就是最好的解决方法。

在递归树中,每个节点代表一个状态,状态包含三个变量 (i, j, edist),其中,edist 表示处理到 a[i] 和 b[j] 时,已经执行的编辑操作的次数。

在递归树中,(i, j) 两个变量重复的节点很多,比如 (3, 2) 和 (2, 3)。对于 (i, j) 相同的节点,我们只需要保留 edist 最小的,继续递归处理就可以了,剩下的节点都可以舍弃。所以,状态就从 (i, j, edist) 变成了 (i, j, min_edist),其中 min_edist 表示处理到 a[i] 和 b[j],已经执行的最少编辑次数。

上一节我们讲的矩阵最短路径问题中,到达状态 (i, j) 只能通过 (i-1, j) 或 (i, j-1) 两个状态转移过来,而今天这个问题,状态 (i, j) 可能从 (i-1, j),(i, j-1),(i-1, j-1)== 三个状态==中的任意一个转移过来。

基于刚刚的分析,我们可以尝试着将把状态转移的过程,用公式写出来。这就是我们前面讲的状态转移方程。

如果:a[i]!=b[j],那么:min_edist(i, j)就等于:
min(min_edist(i-1,j)+1, min_edist(i,j-1)+1, min_edist(i-1,j-1)+1)

如果:a[i]==b[j],那么:min_edist(i, j)就等于:
min(min_edist(i-1,j)+1, min_edist(i,j-1)+1,min_edist(i-1,j-1))

其中,min表示求三数中的最小值。     

了解了状态与状态之间的递推关系,我们画出一个二维的状态表,按行依次来填充状态表中的每个值。

当我们拿到一个问题的时候,我们可以先不思考,计算机会如何实现这个问题,而是单纯考虑“人脑”会如何去解决这个问题。人脑比较倾向于思考具象化的、摸得着看得见的东西,不适合思考过于抽象的问题。所以,我们需要把抽象问题具象化。那如何具象化呢?我们可以实例化几个测试数据,通过人脑去分析具体实例的解,然后总结规律,再尝试套用学过的算法,看是否能够解决。

除此之外,我还有一个非常有效、但也算不上技巧的东西,我也反复强调过,那就是多练。实际上,等你做多了题目之后,自然就会有感觉,看到问题,立马就能想到能否用动态规划解决,然后直接就可以寻找最优子结构,写出动态规划方程,然后将状态转移方程翻译成代码。

如何编程计算最长公共子串长度?

这个问题的解决思路,跟莱文斯坦距离的解决思路非常相似,也可以用动态规划解决。我刚刚已经详细讲解了莱文斯坦距离的动态规划解决思路,所以,针对这个问题,我直接定义状态,然后写状态转移方程。

每个状态还是包括三个变量 (i, j, max_lcs),max_lcs 表示 a[0…i] 和 b[0…j] 的最长公共子串长度。那 (i, j) 这个状态都是由哪些状态转移过来的呢?

我们先来看回溯的处理思路。我们从 a[0] 和 b[0] 开始,依次考察两个字符串中的字符是否匹配。

  • 如果 a[i] 与 b[j] 互相匹配,我们将最大公共子串长度加一,并且继续考察 a[i+1] 和 b[j+1]。
  • 如果 a[i] 与 b[j] 不匹配,最长公共子串长度不变,这个时候,有两个不同的决策路线:
    • 删除 a[i],或者在 b[j] 前面加上一个字符 a[i],然后继续考察 a[i+1] 和 b[j];
    • 删除 b[j],或者在 a[i] 前面加上一个字符 b[j],然后继续考察 a[i] 和 b[j+1]。

反过来也就是说,如果我们要求 a[0…i] 和 b[0…j] 的最长公共长度 max_lcs(i, j),我们只有可能通过下面三个状态转移过来:

  • (i-1, j-1, max_lcs),其中 max_lcs 表示 a[0…i-1] 和 b[0…j-1] 的最长公共子串长度;
  • (i-1, j, max_lcs),其中 max_lcs 表示 a[0…i-1] 和 b[0…j] 的最长公共子串长度;
  • (i, j-1, max_lcs),其中 max_lcs 表示 a[0…i] 和 b[0…j-1] 的最长公共子串长度。

如果我们把这个转移过程,用状态转移方程写出来,就是下面这个样子:

如果:a[i]==b[j],那么:max_lcs(i, j)就等于:
max(max_lcs(i-1,j-1)+1, max_lcs(i-1, j), max_lcs(i, j-1));

如果:a[i]!=b[j],那么:max_lcs(i, j)就等于:
max(max_lcs(i-1,j-1), max_lcs(i-1, j), max_lcs(i, j-1));

其中max表示求三数中的最大值。

解答开篇

当用户在搜索框内,输入一个拼写错误的单词时,我们就拿这个单词跟词库中的单词一一进行比较,计算编辑距离,将编辑距离最小的单词,作为纠正之后的单词,提示给用户。

这就是拼写纠错最基本的原理。不过,真正用于商用的搜索引擎,拼写纠错功能显然不会就这么简单。一方面,单纯利用编辑距离来纠错,效果并不一定好;另一方面,词库中的数据量可能很大,搜索引擎每天要支持海量的搜索,所以对纠错的性能要求很高。

  • 我们并不仅仅取出编辑距离最小的那个单词,而是取出编辑距离最小的 TOP 10,然后根据其他参数,决策选择哪个单词作为拼写纠错单词。比如使用搜索热门程度来决定哪个单词作为拼写纠错单词。
  • 我们还可以用多种编辑距离计算方法,比如今天讲到的两种,然后分别编辑距离最小的 TOP 10,然后求交集,用交集的结果,再继续优化处理。
  • 我们还可以通过统计用户的搜索日志,得到最常被拼错的单词列表,以及对应的拼写正确的单词。搜索引擎在拼写纠错的时候,首先在这个最长被拼错单词列表中查找。如果一旦找到,直接返回对应的正确的单词。这样纠错的效果非常好。
  • 我们还有更加高级一点的做法,引入个性化因素。针对每个用户,维护这个用户特有的搜索喜好,也就是常用的搜索关键词。当用户输入错误的单词的时候,我们首先在这个用户常用的搜索关键词中,计算编辑距离,查找编辑距离最小的单词。

针对纠错性能方面,我们也有相应的优化方式。我讲两种分治的优化思路。

  • 如果纠错功能的 TPS 不高,我们可以部署多台机器,每台机器运行一个独立的纠错功能。当有一个纠错请求的时候,我们通过负载均衡,分配到其中一台机器,来计算编辑距离,得到纠错单词。
  • 如果纠错系统的响应时间太长,也就是,每个纠错请求处理时间过长,我们可以将纠错的词库,分割到很多台机器。当有一个纠错请求的时候,我们就将这个拼写错误的单词,同时发送到这多台机器,让多台机器并行处理,分别得到编辑距离最小的单词,然后再比对合并,最终决定出一个最优的纠错单词。

课后思考

我们有一个数字序列包含 n 个不同的数字,如何求出这个序列中的最长递增子序列长度?比如 2, 9, 3, 6, 5, 1, 7 这样一组数字序列,它的最长递增子序列就是 2, 3, 5, 7,所以最长递增子序列的长度是 4。

2019/11/20 posted in  极客-数据结构与算法之美

43 | 拓扑排序:如何确定代码源文件的编译依赖关系?

2019/12/09 posted in  极客-数据结构与算法之美

44 | 最短路径:地图软件是如何计算出最优出行路径的?

2019/12/09 posted in  极客-数据结构与算法之美

45 | 位图:如何实现网页爬虫中的URL去重功能?

2019/12/09 posted in  极客-数据结构与算法之美

46 | 概率统计:如何利用朴素贝叶斯算法过滤垃圾短信?

2019/12/09 posted in  极客-数据结构与算法之美

47 | 向量空间:如何实现一个简单的音乐推荐系统?

2019/12/09 posted in  极客-数据结构与算法之美

48 | B+树:MySQL数据库索引是如何实现的?

2019/12/09 posted in  极客-数据结构与算法之美

49 | 搜索:如何用A*搜索算法实现游戏中的寻路功能?

2019/12/09 posted in  极客-数据结构与算法之美

50 | 索引:如何在海量数据中快速查找某个数据?

2019/12/09 posted in  极客-数据结构与算法之美

51 | 并行算法:如何利用并行处理提高算法的执行效率?

2019/12/09 posted in  极客-数据结构与算法之美