0%

启动优化已经有很多常规手段,如只加载必要模块,延迟加载等,能取得一些效果,为什么还需要启动框架呢?

一、引言

比如在 Application 中存在很多 SDK,比如20 个,这些 SDK 存在先后顺序,如何保证按照依赖顺序并且高效地执行呢?比如下面的任务:

有向无环图任务

由图我们能看出相互之间的依赖:2 、3 依赖任务 1, 4 依赖任务 2 ,5 依赖 3 和 4。如果使用 Thread 去实现,会变得非常复杂。因此需要框架。

二、有向无环图

如果任务执行有方向(有序),并且没有环。在凸轮中,这种一个有向图从所有顶点出发,无论经过哪些边都不会回到这些顶点,那么就是有向无环图,简称 DAG 图。在 DAG 中:

  • 顶点:图中的一个点,比如任务 1 ,任务 2

  • 边:连接2个顶点的线段

  • 入度:代表当前有多少边指向顶点(依赖多少任务)

  • 出度:代表有多少边从顶点出发(被多少任务依赖)

三、拓扑排序

将我们的启动任务生成 DAG 图后,就要对 DAG 图做 拓扑排序,即对我们的任务启动顺序进行排序。对于前面的有向无环图而言,我们只需要保证 2、3 在 1 之后执行5 在 3、4 之后执行即可,因此我们可以得到多种排序结果:

1 -> 2 -> 3 -> 4 -> 5

1 -> 2 -> 4 -> 3 -> 5

1 -> 3 -> 2 -> 4 -> 5

也就是说,图的拓扑排序不是唯一的!

对 DAG 拓扑排序可以选择 BFS(广度优先)算法或者DFS(深度优先)算法,BFS 算法排序的过程如下:

  1. 找出图中入度为 0 的顶点

  2. 依次在图中删除这些顶点,删除后再找出入度为 0 的顶点

  3. 删除后再找出入度为 0 的顶点,重复执行第二步

基于上面的例子的拓扑排序步骤如下:

拓扑排序例子步骤

步骤2

DAG排序步骤3

代码落地:

1
2
3
4
5
6
7
8
9
10
11
public interface Startup<T> extends Dispatcher {
//执行初始化任务
T create(Context context);

//本任务依赖的任务列表(入度)
List<Class<? extends Startup<?>>> dependence();

//依赖任务的个数(入度个数)
int getDependenciesCount();

}

然后,所有的任务都实现 Startup 接口。

四、线程管理

有些项目写 ConcurrentHashMap 的时候,value 不是直接使用 Object 类型,而是会使用自定义的类似 Result 类型,把原始结果封装了一层。这是什么原因呢?这是因为 ConcurrentHashMap 的 value 不能为 null ,否则会报错。如果我封装一层就能保证 Result 对象是不空的。

CountDownLatch 如果在初始化的时候,传入 0 这个数字,在下面调用 CountDownLatch.await 将不会被阻塞。

SDK中的线程比较多,或者有自己的线程池,那么有几种方法:

  • 线程池设置成自己的线程池

  • 反射改掉它的线程池

  • 字节码增强技术,通过 gradle 插桩修改 SDK 的代码

如果写的 Builder ,让使用者添加 Task ,这样子在 Task 很少的时候,是很方便的,如果有 20 个,100 个的时候就头大了。这时候有什么好的办法去做?有几个方案:

  • 使用注解,每个 Task 上都加上这个注解,然后在

  • 使用 ContentProvider ,在 xml 中把最后的任务写在 data 中

视频中讲解的启动框架源码可以在[github开源项目中看到](android-startup/README-ch.md at master · idisfkj/android-startup · GitHub) ,这个项目比课程中的更加完善

五、问题

有个问题,为什么要先拓扑排序?

假如我们不拓扑排序,会发生什么?假如有task 5 和 6 ,都是在主线程运行,其中 6 是要等 5 结束后才能开始,假如我们不拓扑排序,此时 6 先执行,在这里阻塞了,由于阻塞,此时 5 也不能执行了,又由于在主线程,此时就 ANR 了。

一、概述

分为三个步骤:

  1. 通过源码分析,知道启动流程(便于启动监控——哪些地方耗时)

  2. 阿里的启动优化方案

  3. 如何实现高效的 SplashActivity

二、启动流程

分为三个流程,如下图所示:

App启动的3个流程

在第二个阶段中,Application 的启动很关键,其中 Application 中的 attachBaseContext 方法比较关键,很多时候我们会在里面做一些比较前置的任务,诸如:

  • 对于加固的 App ,这里会做解密的操作

  • 对于热修复而言,第一时间将修复的 dex 给生效

只需要在执行类之前,dex 中包括这个类。所以,理论上我们可以无需一次性将所有的 dex 文件在 attachBaseContext 的时候加载进去。第一个 Activity 启动之后,再将其他的 dex 加载进去。attachBaseContext 阶段可以使用字节的 BoostMultiDex 方案去优化。

只有 window 才能展示出来看到,而目前在 Android 里面就一个 PhoneWindow ,所以,我们看到的第一屏肯定只能是 第一个 Activity 的 PhoneWindow。

那么,在用户点击Launcher 到第一个 Activity 显示出来之前,我们要显示内容,显示什么内容呢?其实显示的是Application 的主题,这个主题里面有个属性:

1
2
3
4
5
6
7
8
9
10
11
12
<application
android:theme="@style/Theme.AndroidDemo">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

application 的 theme 是 @style/Theme.AndroidDemo ,然后我们可以在theme 里面设置 android:windowSplashscreenContent 值:

1
2
3
4
5
6
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.AndroidDemo" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="android:windowSplashscreenContent">@drawable/ic_launcher_background</item>
</style>
</resources>

当然,以上操作要求 API 版本是 26 ,如果要兼容低的版本,就要设置 background 属性,但是这二者还是有区别的:

  • background 只能是图片

  • windowSplashscreenContent 可以做动画

这是怎么实现的呢?Application 里面压根就没有 Window 啊,其实在startActivity 的时候,Activity 真正启动之前,add 了一个 SplashWindow,WindowManager 通过 addView 的方式将 window 交给 WMS 去展示这个闪屏页了,这个闪屏window 会去解析 windowSplashscreenContent 字段。在第一个 Activity 初始化完,Activity 的 window 也通过 vm.addView 展示的时候,就会覆盖 SplashWindow 。

以上就是黑白屏优化,这其实就是欺骗用户,实际上也没加快启动速度。

所以,我们优化启动速度,优化在哪里呢?其实就是 vm.addView 添加 SplashWindow 和 vm.addView 第一个 Activity 的 window 之间的过程,将这个过程尽量缩短。并且,这个过程里面会执行 Application.onCreate(),所以我们需要减少 onCreate 的耗时。

优化方法

线程数,有些 SDK 会启动线程,你自己可能压根没创建线程。所以如何控制这个线程数呢?后续再讲,基本上可以通过阿里的 Alpha 。(老师说Android 的 startUp 比较垃圾),这就是 Application 的 onCreate 方法优化方法。

上述的过程还会经历第一个 Activity 的onCreate、onStart、onResume 这些流程

perfomStartActivity 的过程中, Activity.attach 的时候会为Activity创建 Context 、创建 window ,并未 window 设置 WindowManager。为什么这么设计?因为单一职责问题,Activity 是管理生命周期的,Window 是管理 View 的。

我们在 Activity 的 onCreate 中只应该去做 view 的操作,其他的诸如数据库初始化之类的应该放在子线程中异步操作。

装饰模式是功能增强;代理模式只是代理,没有增强。

有可能面试官会问,为什么采用 inflater 解析的view ,要比自定义的 view 效率低?这里我们可以看 setContentView 方法,最底层就是使用 inflater 实现的,如果能看到后面就会发现,大量使用反射创建出来 View 。而反射又是比较耗时的,所以总结如下:

  • 解析 xml 耗时

  • 根据 xml 反射 view 的反射操作耗时

所以,Activity 的 onCreate 怎么优化?如果采用的还是 xml 就没法优化;只能通过减少 xml 中的布局的层级,减少 view 个数,就减少时间;还有就是从上面的原理来说,可以自己去 new 这些view 。不过也可以用 compose ,它没有 xml 了,通过 new 之类的创建出来的。固定测量方式,避免过度渲染,多次测量问题。

onCreate 执行完成后,只是解析完成了 xml ,在 setContentView 的时候会创建 decorView,然后创建了 window ,但是还没有 显示出来,还要在 wm.addView() 才能显示在硬件上。而 handleResume 的时候,先执行了 onResume 回调之后,然后再执行Activity 的wm.addView() 。

所以,Activity 怎么优化,就得onResume() 以及之前的所有方法(onCreate、onStart) 。所以,在 onResume 这里面如果有耗时操作,就要上前面说的阿里的策略了(Alpha)。以及相应的懒加载思想(ViewStub、Viewpaget+fragment 等)。

层级,深度遍历,多一层 2的次幂

尽量使用 ConstraintLayout 而不是 Linelayout 这些,会增加嵌套。从观法的数据来看,其渲染速度比 RelativeLayout 要提升 40% 。

Activity 中所有的回调都是在 handler 里面执行的。handler 机制体系里面会有MessageQueue ,message 按照时间排序,如果最开始的那个msg 都要 10ms 后执行,那么线程就会通过 epoll 机制睡眠了。所以我们还是利用 IdelHandler 去让主线程空闲的时候去做。比如说,后台 Service 有些内容是没有必要那么及时的,可以考虑在IdleHandler 中执行。

GC 会 STW ,所以,我们也能在 IdleHandler 中取做 GC ,这样就不会影响主线程,LeakCanary里面就是这么做的(老师展示的代码是这样的,AndroidWatchExecutor.waitForIdle() 方法。还需要自己确认下)。要注意一点,IdleHandler 中不要做耗时操作,因为它也是个 msg

当 App 第一个 Activity 的 window 展示将 SplashWindow 覆盖的时候,是在 windowFocusChange 中实现切换的。所以我们一般用 windowFocusChange 来标记新的 window 启动。

每一个 Activity 都会有一个独一无二的 Window

总结

启动优化分为几个点:

  • 黑白屏阶段,使用 manifest 中 theme 的 windowSplashscreenContent 属性

  • Application 的 attachBaseContext() 回调中,可以用 字节的 BoostMultiDex 方案

  • Application 的 onCreate 阶段,采用图论原理,将各个任务依次执行,例如阿里的 Alpha 框架

  • 接着就到了Activity 阶段,由于要遭 onResume 之后才会执行 vm.addView 最终展示 window ,所以在 onCreate、onStart 、onResume 这些回调里面都不要执行耗时操作

USS 很重要,因为这是 Uniq 的,每个进程独占的。

USS、PSS、VSS 等波动用来监测内存抖动

oom_adj 相当于进程的优先级,数值越大越容易被回收

GC Roots 有:

  • 在线程栈中的局部变量(正在被调用的方法的参数和局部变量)

  • 存活的线程对象

  • JNI引用

  • Class 对象(在 Android 中 Class 被加载后不会被卸载的)

  • 引用类型的静态变量

自动化监测流程目标

  • 自动且较为准确监测 Activity 泄漏

  • 自动获取泄漏的 Actiivty 和 冗余Bitmap 对象的引用链

  • 能灵活地扩展 Hprof 的分析逻辑,必要时允许提取 Hprof 文件人工分析

在监测阶段,需要 2 个问题:

  • activity 在执行销毁的时候,我们如何得知

  • 如何判断一个 Activity 无法被 GC 机制回收

可以写自己写过 APM ,或者带几个人做过

我们之前说在 Activity 的 destroy 时触发GC,但是这样不怎么好,我们需要手动触发 GC ,会导致卡顿。所以,一般不使用 registerActivityCallback ,而是通过 阈值触发(如 count 计数),达到阈值时就触发GC (Koom 就是这么做的)。

常见内存泄漏原因

  • 动画问题(在activity销毁时,调用动画的cancel 方法)

  • 匿名内部类

  • InputStream/OutputStream 、cursor 等没有 close

  • xxx未完,后续看老师的笔记

Koom 的分析

如何将 APM 写入简历?

如果自己做过 APM 就更好,如果没有做过就将 APM 接入自己的项目中,然后收集数据,记住那些数据就OK了——路哥

1、Native Heap 泄漏监控:

主要思路是借助 Tracing Garbage Collection(一种垃圾回收算法)来进行监控,GC 回收器的 G1 回收器就是基于这个理论。

google 提供了一个 libmemunreachable 库,我们可以把所有的代码拷贝到 NDK 里面,自己打包成 so 库,就能调用这个库发现 Native 的内存泄漏了。它会告知有哪些地址可达和不可达(reachable)。

Koom 的核心监控代码都在各种 monitor 中,比如 LeakMonitor.kt 等。

爱奇艺的 xHook 能够Hook Native 代码,hook 诸如 malloc 等方法,替换成自己的 malloc 函数,这样就能监测Native 的代码行为。一旦调用了 malloc 等函数,就回调出去。

ida 这个工具可以查看某个 so 里面有哪些 api,这样就能查看它所有的方法。

koom Native 内存泄漏分析的思路:

  • hook malloc/free 等内存分配器方法,用于记录 Native 内存分配元数据(大小、堆栈、地址等)

  • 周期性地使用 mark and sweep 分析整个进程 Native Heap,获取不可达的内存块信息(地址、大小)

  • 利用不可达的内存块地址、大小等,从我们之前记录的元数据中获取其分配堆栈,产出泄漏数据(不可达内存地址、分配堆栈、大小等)

APM 在大厂都只会说原理,没法谈实战的。Android 只有APM 这个知识点可以这样。面试官问你 Koom Native层面 怎样 hook ,用 xhook,怎么知道内存泄漏的,google 官方提供的 libmemunreachable 库

profiler 里面也可以选择当前的进程,然后能看到 Leaks 就可以知道哪些泄漏的。

2、Java 内存监测思路

LeakCanary 采用了弱引用的特性,为Activity 创建了弱引用,但是会在 Activity 的 onDestroy 之后连续触发 2 次 GC,并检查引用队列。但是 GC 会引起用户可感知的卡顿,所以 Koom 采用了无性能损耗的内存阈值监控来触发镜像采集。

Koom 的整体流程如下图所示:

Koom整体流程

3、hprof 文件

使用以下的命令可以导出 hprof 文件:

adb shell am dumpheap

或者在Android 中采用如下代码(会暂时挂起所有线程):

1
Debug.dumpHprofData(fileName)

由于会暂时挂起所有的线程,所以 LeakCanary 在这个时候会非常非常卡,这也是为什么不能用于线上的一个原因。这个时候面试官可能会问,那怎么解决这个问题呢?开子线程行不行?

肯定是不行的,因为这时候挂起了所有线程,包括你的这个子线程。

解决方案:

  • fork 子进程去执行 dumpHprofData 方法

  • fork 进程采用的是 “copy On Write” 技术,只有在进行写入操作时,才会为子进程拷贝分配独立的内存空间。默认情况下,子进程可以和父进程共享同个内存空间。所以,当我们要执行dumpProfData 方法时,可以 fork 一个子进程,它拥有父进程的内存副本,然后在子进程中取执行 dumpProfData 方法,而父进程可以正常继续运行。

这个过程的流程图如下所示:

fork子进程获取内存镜像

hprof 文件很大,一般可达 1G 的规模,所以不可能直接丢给后台去分析,一般会做以下裁剪。这时候就看你具体要什么,可能不同的公司需要的不一样,但是大体上需要看 bitmap、byte 数组 byte[] 等。有个 shark 工具可以用来分析这个 hprof ,便于后续的裁剪。 我们关注的主要是 三类 信息:

  • 字符串信息,保存着所有的字符串,在解析时可以通过索引id引用

  • 类的结构信息:包括类内部的变量布局、父类信息等

  • 堆信息:内存占用与对象引用的详细信息

裁剪的主要思路如下:

  1. 读取 Hprof 文件

  2. 记录 Bitmap 和 String 类信息

  3. 移除 Bitmap buffer 和 String value 之外的基础类型数组

  4. 将同一个图片的 Bitmap buffer 指向同一个 buffer id,移除重复的 Bitmap buffer

  5. 其他数据原封不动地输出到新文件中

性能优化里面最难的就是 内存,后续的 FPS 之类的就比较简单了。

落脚点

启动优化

apm 的 demo

LeakCanary 只能在线下,不能用于线上。LeakCanary 有什么缺点?线上怎么做?

只能对 Acitvirty 和 Fragment 去做

强引用:宁愿 OOM 也不会回收的

软引用:内存足够的时候,即时发生GC,也不会被回收,但是内存不足的时候,就回收了

一、OOM 与内存优化

启动优化,APM 框架

日志系统,使用 mmap 系统崩了也不会丢

使用 xlog

Linux 内核动态内存分配,所以 MemAvailable 不一定准确

RSS 实际使用的物理内存——包含了 共享库的内存(so动态链接库),所以容易误导

python 做自动化测试,python 不断读取那些数据

Android 会对所有进程分类,每一个类别都有其 oom_adj 的值取值范围, oom_adj 的值越大,说明越不重要。因此,内存不足 kill 进程时,从 oom_adj 高的进程开始杀

如果想要一个服务长期运行,应该将其运行在单独的进程里,即 UI进程与 Service 进程分离,这样就能获得较小的 oom_adj 值,就容易存活。而占有大量内存的 UI 进程会分类为 Cached 进程,能够在需要的时候更快地被回收。

LeakCanary 的源码

里面注册了 ContentProvider ,为啥?

线上监测

  • 端上解析:就是在设备上做引擎解析,自己解析内存泄漏

  • 后台解析: 内存镜像,fork 出当前进程,然后将子进程信息发给后台

监测 Activity 生命周期可以使用 RegisterActivityLifecycleCallback 的方式,然后使用 WeakHashMap 来监测回收,因为 WeakHashMap 它的 Key 是弱引用,它里面有个 ReferenceQueue 。

在 ActivityLifecycleCallback 的 onActivityDestroy 回调中,以 Activity 的方式作为 key ,value 可以使用 activity.getClass().getSimpleName(),在 WeakHashMap 中就可以监听 key 的回收,就能监听 Activity 的回收了。类似于如下代码:

1
WeakHashMap<Activity, String> datas = 。。。

然后,在 onActivityStopped 回调中,因为 activity 此时不可见了,可以使用 GC 来找泄漏的 Activity :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void onActivityStopped(){
//申请一个大的 byte 数组,便于后续的 GC 触发
byte[] bytes = new byte[N];
//延时
SystemClock.sleep(100);
//GC,使用 runtime的gc 更容易触发 gc 一点
Runtime.getRuntime().gc();
//课程里面说用这个来复活,而不是直接用上面的 gc ,没看懂意思,后续深究
//System.runFinalization();

//从之前的 WeakHashMap 中遍历所有的 Activity

//分析内存
Debug.MemoryInfo debug = new Debug.MemoryInfo();
Debug.getMemoryInfo(debug);
//就可以获取很多内存信息了
int nativePss = debug.nativePss() >> 10;
}

当然,这里面还需要很多判断,比如判断 Activity 是否在后台。有个同学提出了,如果面试官说这里每次 Activity 的 onStop 都去做这些操作,是否合理?那也确实不合理,假如,只是做了 Activity a 跳转到 Activity b,触发了 a 的 onStop ,这里就有点浪费。所以呢,这里还需要判断 ,其实也是判断 Activity 是否在后台(可能不一定对,听课是这样理解说判断是否在后台)。

这里获取 pss 也能生成曲线图,这个曲线图有什么用呢?就是监测在运行某个功能时,内存占用的问题,就能定向排查问题

爱奇艺的 xhook ,用于 nativeHook

路哥性能优化课程的产出:

  1. 启动优化框架

  2. 简单的 APM 框架

一、概述

如果想自己写公司的apm,可以参考koom和matrix这2个来源框架

做完a任务和b任务,就可以做c任务,这时候可以用countdownLatch来实现​

做性能优化,很重要的一点就是线上的APM​

二、Java层的实现

简历中一定要有指标

  • cpu指标,如负载,线程数等

  • 内存指标

  • FPS

  • ANR

  • 卡顿

  • GC/oom还是和内存相关,gc日志

  • 网络,hook到okhttp

  • 远程下发,日志回捞,下发shell,执行动态代码

  • crash怎么监测?其实就是监视线程,java层有uncatchexception

  • anr怎么监测?就是监测文件,file obsever,监测anr文件

  • 用户行为链路,其实就是监测activity和fragment。在application中通过lifecyclecallback即可

可以说我们公司有做了类似头条的 APM,有几个人做,参考了xx,xx框架实现了

三、APM

首先讲设计思路。

1、配置

做 APM 肯定是有配置的,类似于:

1
2
3
4
5
6
7
8
String[] params default {}


[{
"clazz": "",
"name": "Test",
"params": ["a", "b"]
}]

配置都是使用 注解 + Json 实现 (课程里面还没说怎么用到的注解)

2、cpu与gc

使用linux命令​如prco/stat等

3、anr

面试官问一个问题,首先要回答有哪些知识点,看过哪些框架​,比如做性能监测,面试官问你是启动优化吗?可能不那么好回答,但是可以说做了类似抖音,类似快手的APM,别人就明白了。然后就是几个人做,大概做什么我负责哪些部分,我看了哪些框架源码,我自己是怎么想的。

anr监测主要就是文件监听,监听data/anr/trace.txt​

4、fps帧率

使用 Geographer(单词可能写错,翻译为:编舞者​)

四、如何看项目源码

  1. 首先看构造函数

  2. 其次浏览属性,不一定能全部看懂,粗略知道含义

  3. 看其重要的 api 中的具体实现

看代码的时候,需要思考别人为什么这么设计。比如别人使用数据结构是基于什么考虑,空间换时间,还是时间换空间。 老师以 ArrayList 为例分析了这个过程。

这里有个知识点,ArrayList 扩容的时候,变成以前的1.5倍

HashMap 的 getNode() ,这个方法很精华:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
final Node<K,V> getNode(Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & (hash = hash(key))]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

由于在一定时候会将链表转为红黑树,所以在 get 的时候需要判断是树还是链表:

1
if (first instanceof HashMap.TreeNode)

这个判断是否是树,是树就从红黑树中获取,是链表就do-while 遍历链表,直至找到或者遍历到最后一个结点为止。

这里面有个值得注意的点,用 & 符做求余的操作,根据 hash 值求 index :

1
2
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & (hash = hash(key))]) != null)

通过 (n - 1) & (hash = hash(key)) 确定当前 key 应该在 HashMap 中数组的位置。注意一下,只有当 n 是 2 的 多少次方的情形才适用,采用 & 操作进行求余的原理可以参考:

java取余位运算_使用位运算&取余_陈安研究员的博客-CSDN博客

五、代码性能评测

在公司证明自己做的事情需要做测试数据,比如说 SparseArray 、ArrayMap 来替换 HashMap ,怎么证明性能更高?那就适用 几个,几千,几万个数据,然后通过 Android Studio 的 Profiler 去可视化查看Memory 指标中 Java 方面占用的空间。

老师解析 SparseArray 源码的时候,put 操作值得关注:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public void put(int key, E value) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

if (i >= 0) {
mValues[i] = value;
} else {
i = ~i;

if (i < mSize && mValues[i] == DELETED) {
mKeys[i] = key;
mValues[i] = value;
return;
}

if (mGarbage && mSize >= mKeys.length) {
gc();

// Search again because indices may have changed.
i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
}

mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
mSize++;
}
}

如果已经存在key-value了,那就直接覆盖,否则,看起来通过

1
i = ~i;

即对 i 取反操作就能获取到要存入的位置,这是什么原理?其实这是个技巧,避免了再次计算一次 index ,总而言之就是取反操作(~) 的性质

  • 当 n 为正数时, ~(n) = -(n + 1)

  • 当 n 为负数时, ~n = (-n) - 1

所以,上述判断 i 的逻辑就比较通顺了:

1
2
3
4
5
6
if (i >= 0) {
mValues[i] = value;
} else {
i = ~i;
//。。。。
}

如果找到位置当前有值了,直接覆盖;否则,取反得到真正的 index ,然后根据判断将 key 和 value 存入。附 binarySearch (二分查找)的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int binarySearch(int[] array, int size, int value) {
int lo = 0;
int hi = size - 1;

while (lo <= hi) {
final int mid = (lo + hi) >>> 1;
final int midVal = array[mid];

if (midVal < value) {
lo = mid + 1;
} else if (midVal > value) {
hi = mid - 1;
} else {
return mid; // value found
}
}
return ~lo; // value not present
}

1、SparseArray 和 HashMap 比较:

  • 2个数组key数组和value数组

  • 默认是10的大小,hashmap为16

  • 不需要包装

  • 二分查找,稍微低了点点,HashMap 的hash算法效率高

所以,SparseArray 的主要优势是节省内存。

本质:二叉树来维护一维数组

堆它和树有什么区别?堆不用指针(Node),树会有指针,所以堆更省内存。

二叉树最后一个非叶子节点:(length - 1) /2,排序首先动这个结点。

《算法导论》太难就看《算法4》

opencv 和 ffmpeg 不建议去做,深入太难了

flutter 也不要去写 API ,要去架构,引擎的东西

路哥活得很通透:

大起大落,讲课的时候失去了所有,名言:

  • 人总要失去些什么才能成长

  • 除了生与死,其他的都是小事

算法不用怕,早晚半个小事,坚持3年,大厂没问题

总要去做架构,假如以后的部门黄了

vulcan 是 openGL 的升级版

录音分析,自己面试为什么会失败,一句话一句话听

json 的 key 必须是 String 类型,因此不能是 null。

json中的数字支持正/负数,还支持以 e 表示的方式

Gson 中 @SerializedName 表示的是转换为json 的时候的名字,和目前的本身的名字没什么关系。

1
2
@SerializedName("sex")
public String a;

Gson 普通使用一般都是使用反射去做,不能自定义序列化过程,我们可以设置 JsonAdapter ,自己控制序列化过程。GsonBuilder 去设置:

1
Gson gson = new GsonBuilder().xxxxx.create();

使用 Gson 解析字符串的时候,碰到类似:

expected BEGIN_ARRAY but was STRING

之类的错误,可以通过自定义 TypeAdapter 或者 JsonDeserializer 的方式去解决。这里面注意,在使用 GsonBuilder 构建的时候,可以关注 serializeNulls

Gson 是基于事件驱动的解析方式,它可以不要求一次性将数据全部加入内存。它的工作流程是:

  • 假如解析加载的数据碰到 “{“ ,那就说明碰到 jsonObject 了,则现将 “{“ 入栈,然后等待后续的 “}” 的时候再一起出栈组这个 jsonObject

  • 其他的诸如碰到 “[“ 也是一样的,都是先压栈,等配套的符号来了之后再一起出栈

JsonElement 有 4 种实现类,分别是:

  • JsonNull

  • JsonObject

  • JsonArray

  • JsonPrimitive:Json基本类型,比如 String 、int、number等

Gson 源码解析这次就不看了,下次。

一、synchronized 的实现原理

如果在方法里面使用 synchronized(obj) 这种用法,那么 javac 在编译的时候会插入 monitorenter 和 monitorexit 字节码。规则如下:

  • monitorenter 指令是在编译后插入到同步代码开始的位置;而monitorexit是插入到方法结束处和异常处

  • 每个monitorenter 必须要有对应的 monitorexit 配对

  • 任何对象都有一个 monitor 与之关联

示意图如下所示:

在方法里面使用

如果在使用 synchronized doSomething() 这种方式的话,会在字节码上的 flag 上添加 ACC_SYNCHRONIZED 标记,不过底层原理还是 monitor 这套。示意图如下:

synchronized直接修饰方法

一般的 Java 对象头(除了对象体和数据外的部分)会包括 2 个部分,如果是数组对象就会新增数组长度:

  • MarkWord: GC 信息、 hashCode、锁信息

  • KlassPoint: 对象所属的类

  • ArrayLen(是我自己写的英语,不一定是这个): 数组长度

上述说的锁信息,包括锁状态、是否偏向、锁标志位等,当然,需要注意一点的是,对象头的锁信息不是一成不变的。如:偏向锁时,存放的是偏向线程的 id;轻量级锁的时候,存放了栈中锁记录的指针;重量级锁的时候,存放互斥量(重量级锁)的指针。

二、一线大厂的面试题

1、synchronized 修饰普通方法和静态方法有什么区别?

synchronized 锁一定是和某个对象相关联的,加锁要加在具体地对象上。普通方法修饰当前对象,静态修饰class 对象。思考下:同时存在被 synchronized 修饰的普通方法和 静态方法,这二者能不能同时执行?

1
2
3
4
5
6
7
public synchronized void normalMethod(){

}

public static synchronized void staticMethod(){

}

答案是肯定能够同时执行的,因为这二者压根锁的不是同一个对象,是2个不同的锁,所以肯定可以同时执行。

2、什么是可见性

这涉及到工作内存和主内存。多个线程共享一个变量,如果一个对象更改了这个共享值,其他线程也能立即看到修改值。那如何保证可见性呢?采用 volatile 和 加锁

3、锁的分类

根据不同的场景可以将锁分为多类,如下图所示:

锁的分类

3、CAS的原理

主要利用现代CPU提供的 比较并交换(CAS)指令,这个指令具有原子性。CAS 存在的问题:

  • ABA问题

  • 开销问题

  • 只能适应简单变量问题

4、ReentrantLock 实现原理

进入一次锁,state 就加 1 ,退出一次锁,就减 1 。当然,底层是采用 AQS 实现的

5、AQS 原理

可重入锁、CountDownLatch 、读写锁 等都是使用 AQS

如果需要自己定义一种锁,只需要创建静态内部类,继承 AQS ,这是模板类,只需要实现几个关键方法如 tryAcquire 即可

6、Synchronized 原理

Synchronized(obj) 的形式是基于 monitorenter 和 monitorexit 字段

修饰的是方法的话,在字节码中的Flag 字段中会添加 ACC_SYNCHRONIZED 标记

7、synchronized 和 ReentrantLock 区别

一个是显式锁,一个是关键字,内置所

synchronized 是非公平锁,ReentrantLock 可以公平和非公平

8、锁的优化

偏向锁

轻量级锁

锁消除(与后续的逃逸分析联系比较紧密)

锁粗化

逃逸分析

9、volatile 在 DCL上的作用

new 一个对象会有3个步骤:

  • 分配内存

  • 初始化空间

  • 赋值给引用

但是会有指令重排序,上述的 3 个步骤不一定按顺序完成,如果分配内存、赋值引用执行了,但是初始化空间还没完成,这时候双重检查的单例方式可能会使用初始化一半的对象,使用就会报错。

所以需要 volatile 关键字修饰

10、如何退出一个线程

采用线程的 interrupt 方法,利用中断机制来退出线程。

11、线程池原理

为什么要用线程池:

  • 降低资源消耗

  • 提高响应速度

  • 提升线程的可管理性

再讲一讲线程池里面各个参数的含义。后续的线程拒绝策略,系统提供的4种拒绝策略

12、t1、t2、t3 三个线程,如何保证按顺序执行

采用 join 方法嘛

三、了解synchronized升级过程

通过CAS操作来加锁和解锁。采用自旋锁方式获取锁,万一其他线程很快释放锁呢。避免阻塞。

oracle 发现在很多情况下,1个锁在很多情况下都是同一个线程来获取的,所以这时候 CAS 操作都不想做了,这时候就产生了 偏向锁。(不过markword的字段更改的时候还是得用CAS操作的)

synchronized 整个的升级过程如下图所示:

synchronized升级过程

偏向锁和轻量级锁的对象头是完全不一样的,所以在撤销偏向锁改为轻量级锁的时候,需要替换 markword 。

但是撤销偏向锁的时候,会 STW ,撤销偏向锁的时候,需要修改线程 1 的堆栈内容的。markword 会存放在 线程 1 的堆栈里面,线程 2 要跨线程把线程1的堆栈的数据改过来。所以需要 Stop The World

一、AQS的基本思想CLH队列锁

维护了一个链表,每个节点包含以下几个属性: 当前线程本身、我的前驱结点 以及 locked 标记(默认为true,当为false的时候,意味着释放锁了),示意图如下:

CLH队列锁结点示意图

当有线程需要获取锁的时候,便构造一个QNode类型结点 ,通过 CAS 操作将这个结点接入到链表尾部,同时将结点的前驱指向之前的尾部结点,自己则成为新的尾部结点(tailNode)。

获取锁的阶段:每个结点不断轮询前驱节点的 locked 是否为 false ,否的话,则 yield 等待下一次判断;如果是的话,则意味着前驱结点已经释放了锁,接下来该自己获取锁了。当然,实际操作可能还需要额外操作(比如公平锁时还需要判断自己当前是否是头结点)

CLH队列锁提供了一个很好的思路,但是一直自旋会消耗资源,因此,AQS 在这个思路上做了一些改进:

  • 链表改为了双向链表,而不是之前的单向

  • 控制自旋次数(一般2到3次),当自旋到一定次数后,就会阻塞挂起

二、公平锁和非公平锁

我们以 ReentrantLock 为例,看下它在公平锁和非公平锁时的代码实现,首先是非公平锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static final class NonfairSync extends Sync {
final boolean initialTryLock() {
Thread current = Thread.currentThread();
if (compareAndSetState(0, 1)) { // first attempt is unguarded
setExclusiveOwnerThread(current);
return true;
} else if (getExclusiveOwnerThread() == current) {
int c = getState() + 1;
if (c < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(c);
return true;
} else
return false;
}
}

公平锁的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static final class FairSync extends Sync {
final boolean initialTryLock() {
Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedThreads() && compareAndSetState(0, 1)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (getExclusiveOwnerThread() == current) {
if (++c < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(c);
return true;
}
return false;
}
}

可以看出二者并没有什么区别,只有在 if 条件里面,公平锁多了 一个 !hasQueuedPredecessors() 判断,这个方法的含义是这样的:

Queries whether any threads have been waiting to acquire longer than the current thread.

这也就是公平的含义了,要判断是否有其他线程比当前这个线程等待时间更久。

三、可重入

假如有 2 个成员方法,a() 和 b() ,都使用 synchronized 关键字修饰,其中 a 方法里面 调用了 b 方法,因为 synchronized 关键字的锁是可重入的,所以能够正常执行,但是,如果锁是不可重入的话,会发生什么?

答案是会发生死锁,自己把自己给锁死了。因为 b 方法获取锁执行 compareAndSetState(0, acquires) 的时候肯定是不成功的,因为 a 方法的时候已经将这个值设置为 1 了。

1、实现

可重入锁是怎么实现的呢?还是在上述代码中,我们能看到这样的判断:

1
else if (getExclusiveOwnerThread() == current)

也就是当前获得锁的线程是否就是自己,如果是自己的话,就直接进入了!这就是可重入锁的奥秘。

这里聊下为什么用 ++state 操作来标记重入锁的这个 state 状态,这主要是因为方便释放锁,退出一个同步块 的时候只需要将 state 减去 1 即可,一直减到 0 就释放了最外层的锁了

四、JMM(Java 内存模型:Java Memory Model)

看下 Google 大牛做的报告:

速度概念

如果要做 a + b 的操作,CPU 读取 a 和 b 这 2 个值花费了 200ns ,而真正的计算只花了 0.6ns ,所以需要cpu里面需要高速缓存。

工作内存和主内存是抽象概念,工作内存可能是寄存器、cpu高速缓存等,主内存可能主要指内存条。每个线程里面的工作内存是独享的,不能访问其他线程的工作内存。

五、volatile 详解

只保证可见性和有序性(禁止指令重排),对于复合指令(如 i ++)是没有原子性的。怎么做到的呢?

volatile 变量修饰的共享变量在进行写操作的时候会适用 CPU 提供的 lock 前缀指令,它的作用是:

  • 将当前处理器缓存行的数据写回系统内存

  • 写回内存的操作同时让其他 cpu 缓存的该变量数据失效

适用的场景:

  • 一个线程写,多个线程读

  • 多个线程写没有关联

比如 count = count + 1 那么,这个 count 是跟以前的值是有关联的。而 count = 5 之类的操作是没有关联的

一般可以用 volatile + CAS 操作来替换 synchronized 来提升效率, 如 ConcurrentHashMap

刚野课看到的

RecycleView用了自定义View ,View 里面有动画,动画的 update 里面执行了 invalidate ,这就会触发 draw 方法,而 draw 方法里面new 了 多个 Path ,造成内存抖动。会引起动画的卡顿

一、分而治之思想

算法中分而治之思想和动态规划思想有点类似,要注意区分:

  • 分而治之: 将一个任务分割成很多小任务,并且各个任务之间无关联

  • 动态规划: 将一个任务 A 分割成很多小任务,比如 a1、a2、a3 ,假如存在 a1 执行完成才能执行a3,那就是小任务有关联性了,这就是动态规划思想了

快排,归并,二分都是分而治之的思想。

在 Java 7 中提供了 Fork/Join 工具(Fork:将任务拆分成很多独立的子任务;Join:将所有的子任务结果合并)来让分而治之的思想并发执行起来。当然,我们也可以不使用这个框架,自己将任务分成很多小任务,然后用多线程去执行。不过呢,Java 的 ForkJoin 给提供了工作窃取(work-stealing) 算法,能够让执行更高效。

2.1 工作窃取

工作窃取(work-stealing) 简单来说就是空闲线程试图从繁忙线程的 deques 中窃取工作。

默认情况下,每个工作线程从自己的双端队列中获取任务,但如果自己队列中任务已经执行完成,队列为空时,它就会从另一个繁忙线程的双端队列尾部或者全局入口队列中获取任务,因为这是最大概率可能找到工作的地方。

为什么会有任务先执行完成呢?因为工作量的划分不一定均等。即使是相同数量的数字相加,比如,每个任务都是10个数字相加,如果数字很大的话,耗时也会很长。

二、Java 提供的 ForkJoin

Java 提供分而治之的框架: ForkJoin。既然是要提交任务,我们需要对任务进行封装,ForkJoin 提供了 ForkJoinTask 来包装任务:

1
public abstract class ForkJoinTask<V> implements Future<V>, Serializable

不过一般不直接使用它,而是用它的 2 个主要的子类:

1
2
3
4
public abstract class RecursiveTask<V> extends ForkJoinTask<V>


public abstract class RecursiveAction extends ForkJoinTask<Void>

从这 2 个子类我们能看出,RecursiveTask 更适合有返回值的情形;RecursiveAction 适合没有返回值的情形。ForkJoinTask 它有2种使用方法:

  • 同步用法:采用 invoke 来提交任务

  • 异步用法:采用submit 或者 execute 方法来提交任务

使用 ForkJoin 的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) {
int[] src = MakeArray.makeArray();
//第一步,创建池子
ForkJoinPool pool = new ForkJoinPool();
//第二步:构建任务
SumTask innerFind = new SumTask(src, 0, src.length - 1);

long start = System.currentTimeMillis();
//第三步、提交,invoke 意味着同步执行
pool.invoke(innerFind);
//第四步、获取结果
System.out.println("the result is " + innerFind.join() +
",spend time:" + (System.currentTimeMillis() - start));
}

其中,makeArray 只是用来生成一个数组:

1
2
3
4
5
6
7
8
9
10
11
12
public class MakeArray {
public static final int ARRAY_LENGTH = 4000;
public final static int THRESHOLD = 47;

public static int[] makeArray() {
int[] result = new int[ARRAY_LENGTH];
for (int i = 0; i < ARRAY_LENGTH; i++) {
result[i] = i;
}
return result;
}
}

真正的任务代码,由于我们需要有返回值,所以我们继承了 RecursiveTask :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class SumTask extends RecursiveTask<Integer> {

private static final int THREASHOLD = MakeArray.ARRAY_LENGTH / 10;
private int[] src;
private int fromIndex;
private int toIndex;

public SumTask(int[] src, int fromIndex, int toIndex) {
this.src = src;
this.fromIndex = fromIndex;
this.toIndex = toIndex;
}

@Override
protected Integer compute() {
//判定任务大小是否合适
if (toIndex - fromIndex < THREASHOLD) {
//满足要求,计算结果
System.out.println("from index = " + fromIndex + ",toIndex = " + toIndex);
int count = 0;
for (int i = fromIndex; i <= toIndex; i++) {
//Thread.sleep(1);
count = count + src[i];
}
return count;
} else {
//还不满足要求,则继续拆分任务
int mid = (fromIndex + toIndex) / 2;
//拆分出左边的任务
SumTask left = new SumTask(src, fromIndex, mid);
//拆分出右边的任务
SumTask right = new SumTask(src, mid + 1, toIndex);
//提交到我们创建的 pool 中
invokeAll(left, right);
return left.join() + right.join();
}
}
}

在重写的 compute 方法中,我们不断拆分任务,直至任务满足我们定义的阈值位置。计算量越大,中途 IO 操作越多 或者 休眠时间越多, ForkJoin 的优势越明显。如果只是少量数据,那么由于:

  1. ForkJoin 的递归调用

  2. ForkJoin 多线程切换

反而导致还没单线程那么快。但是当计算过程中有IO 操作或者休眠时间时,ForkJoin 在大量数据情况下的优势就会显示出来。我们注意上述代码中注释的代码 : Thread.sleep(1); 如果在单线程计算和 ForkJoin 计算过程中都休眠 1ms ,后续导致的耗时差异是很大的,这个可以自行验证。

上面我们看了同步的操作,接下来看下如果进行异步操作,以下代码将根据指定的文件中,所有的 txt 文件,有可能文件下还有目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) throws Exception {
ForkJoinPool pool = new ForkJoinPool();

FindDirFiles task = new FindDirFiles(new File("D:/"));

//异步提交
pool.execute(task);

//主线程做自己的事情
System.out.println("Task isRuning");
Thread.sleep(1);

System.out.println("doing other works");

//获取结果,这是一个阻塞方法
task.join();

//由于上述join 是阻塞方法,所以一定得有结果之后,才会打印下面的 task end
System.out.println("task end");
}

首先 main 方法中调用与上述的同步差不多,只不过在使用 execute 异步调用后,主线程的日志和子线程的日志同时在打,最后,我们会调用 task.join 阻塞方法,这也导致当所有文件遍历完成后,才会在main 线程中 输出最后的 task end 语句。我们看下 FindDirFiles 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class FindDirFiles extends RecursiveAction {

private File path;

public FindDirFiles(File path) {
this.path = path;
}

@Override
protected void compute() {
List<FindDirFiles> subTasks = new ArrayList<>();
File[] files = path.listFiles();
if (files != null) {
for (File file : files) {
if (file.isDirectory()) {
//对每个子目录新建一个子任务
subTasks.add(new FindDirFiles(file));
} else {
//遇到文件,检查
if (file.getAbsolutePath().endsWith("txt")) {
System.out.println("文件:" + file.getAbsolutePath());
}
}
}

if (!subTasks.isEmpty()) {
//在当前的 ForkJoinPool 上调度所有的子任务
for (FindDirFiles subTask : invokeAll(subTasks)) {
subTask.join();
}
}
}

}
}

由于不需要返回结果,所以我们继承 RecursiveAction 即可,在 compute 中,我们将为每个目录创建一个 Task 加入 list 中,最后一并 invokeAll 执行。

三、CountDownLatch

它的作用是等待一个线程或者多个线程执行完成后再执行后续的操作。比如说要等所有的初始化工作(多个子线程)执行完成之后,才进入主线程执行。

对于 CountDownLatch 而言,计数器和线程数量是没关系的,有可能在同一个线程中做 2 个任务执行 2 次 countDown 操作。

线程做完 countDown 操作之后,能够继续执行,并不是说一定就要关闭之类的。

CountdownLatch 的 await 方法(即等待所有的扣减到 0 后才能执行的地方)可以在多个地方使用,比如同时在 主线程调用 countDownLatch.await() 以及在某个子线程中调用 countDownLatch.await() ,当 countDownLatch 计数器减为 0 的时候,主线程和那个子线程都会被唤醒接着执行。

四、CyclicBarrier

它的主要思想是,在某个点设置一个屏障, 比如 3 个线程,如果 A 线程先执行完成,它会 await 在那里等待;同样 B 线程也会这样,一直到 C 线程也执行完成,此时,3个线程才一起执行。它有 2 个主要构造方法:

1
2
3
4
//parties 表示操控的线程数量
public CyclicBarrier(int parties) {

}

以及:

1
2
3
public CyclicBarrier(int parties, Runnable barrierAction) {

}

其中第二个 构造方法的 barrierAction 表示的是,当所有的操控的线程都执行到这个屏障的时候,此时优先执行一下这个 Rnnable ,之后三个线程才继续执行。这样做的一个好处就是:

举个例子吧,如果3 个线程计算一个任务的 3 个部分,这时候,我们可以用设置这个 Runnable 来合并这 3 部分结果,之后,这 3 个线程都拿着合并后的结果执行后面的操作。

五、CountDownLatch 与 CyclicBarrier 的区别

虽然二者在功能上很相似,但其实是有很大区别的,主要体现在:

  • CountDownLatch 定义的数字只能扣减一次,CyclicBarrier 能多次调用 await ,后续的 await 还都能触发 barrierAction 的执行

  • CountDownLatch 用外部线程协调;而 CyclicBarrier 本身相互协调

  • CountDownLatch 扣减数和线程数不一样,比如一个线程可以 countdown 多次;但 CyclicBarrier 初始化时传入的 数量必须是管控的线程数量

  • 这点没大听懂,大体是说 CountDownLatch 运行中不允许其他线程执行;而CyclicBarrier 可以通过 barrierAction 执行其他线程任务。还需要额外确认这点

六、Semaphore

主要用于流控的场景。比如,数据库最多只能有 10 个连接,那么我们可以通过 Semaphore 发放 10 个 许可证。在正常使用的时候通过 acquire 和 release 来控制获取和释放操作。还是看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
static class ConnectionImpl implements Connection {
。。。省略实现
}


public class SemaphoreDemo {
//许可证数量
private final static int POOL_SIZE = 10;

//2个新号指示器,分别表示 池子还有可用的连接 / 已用的连接
private final Semaphore useful, uselessful;
//存放资源的池子
private static LinkedList<Connection> pool = new LinkedList<>();

static {
//初始化放入资源
for (int i = 0; i < POOL_SIZE; i++) {
pool.addLast(new ConnectionImpl());
}
}

public SemaphoreDemo() {
this.useful = new Semaphore(POOL_SIZE);
this.uselessful = new Semaphore(0);
}

//归还连接
public void returnConnection(Connection connection) throws Exception {
if (connection != null) {
System.out.println("当前有" + useful.getQueueLength() + "个线程等待连接,"
+ "可用连接数:" + useful.availablePermits());
//通过 uselessful 的 acquire 来获取空位
synchronized (pool) {
pool.addLast(connection);
}
//许可证释放
useful.release();
}
}

//获取连接
public Connection takeConnect() throws Exception {
//获取许可,如果没有许可可能阻塞
useful.acquire();
Connection connection;
//拿到许可,为什么还需要同步?
//这是因为如果有 4 个许可,那么可能同时有 4 个线程过来取值,所以,还需要对池子同步
synchronized (pool) {
connection = pool.removeFirst();
}
//空位释放
uselessful.release();
return connection;
}
}

接下来就是编写 Demo 使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private static class BusiThread extends Thread {
private static SemaphoreDemo semaphoreDemo = new SemaphoreDemo();

@Override
public void run() {
super.run();
//随机,让每个线程持有的连接时间不一样
Random r = new Random();
long start = System.currentTimeMillis();

try {
Connection connection = semaphoreDemo.takeConnect();
System.out.println("Thread_" + Thread.currentThread().getName()
+ "获取数据库连共耗时【" + (System.currentTimeMillis() - start) + "】ms");
//模拟业务操作
Thread.sleep(100 + r.nextInt(100));
System.out.println("查询数据完成,归还连接");
semaphoreDemo.returnConnection(connection);
} catch (Exception e) {
e.printStackTrace();
}
}
}


public static void main(String[] args) {
for (int i = 0; i < 50; i++) {
Thread t = new BusiThread();
t.start();
}
}

上述代码会显示出前面 10 个线程几乎是 0ms 就获取到资源,而后续的会等待:

Thread_Thread-1获取数据库连共耗时【0】ms
Thread_Thread-0获取数据库连共耗时【0】ms
Thread_Thread-2获取数据库连共耗时【0】ms
Thread_Thread-3获取数据库连共耗时【0】ms
Thread_Thread-4获取数据库连共耗时【0】ms
Thread_Thread-5获取数据库连共耗时【0】ms
Thread_Thread-6获取数据库连共耗时【0】ms
Thread_Thread-7获取数据库连共耗时【0】ms
Thread_Thread-8获取数据库连共耗时【0】ms
Thread_Thread-9获取数据库连共耗时【1】ms
查询数据完成,归还连接
当前有40个线程等待连接,可用连接数:0
Thread_Thread-10获取数据库连共耗时【113】ms
查询数据完成,归还连接
当前有39个线程等待连接,可用连接数:0
Thread_Thread-11获取数据库连共耗时【124】ms
查询数据完成,归还连接
当前有38个线程等待连接,可用连接数:0
Thread_Thread-12获取数据库连共耗时【141】ms

。。。省略一部分运行数据

这里可能大家有个疑问,为什么需要定义 2 个 Semaphore: useful, uselessful。其中 前者是代表可用的许可,后者代表空位。为什么这么设计?还得从 Semaphore 的机制说起:即使没有调用 acquire ,而是直接调用 release ,这样也是可以的,可以在release 的时候传入一个 new 出来的Connection 对象 !!!,假如出现这种情况的话,我们的许可证会越来越多,而不是我们初衷的限制了。

acquire 只是做流控,但是不能做同步操作,比如,它有 4 个许可证,意味着可以允许 4 个线程同时去取资源,这时候我们还是需要对存储资源的池子做同步的。 所以,我们上述的代码 takeConnect() 方法中,在 acquire 之后,还需要 synchronized (pool) 操作:

1
2
3
4
5
6
7
8
9
public Connection takeConnect() throws Exception {
//获取许可,如果没有许可可能阻塞
useful.acquire();
//省略无关代码。。。
synchronized (pool) {
connection = pool.removeFirst();
}
//省略无关代码。。。
}

七、Exchange

Exchange 的使用场景很少。它的含义是:两个线程 A 和 B,在某一位置设定一个点,当某个先执行完了,就会等待另一个线程。当 2 个线程都执行到这个点时,二者交换数据,然后接着继续执行,没错,只是交换数据。

八、Callable 、Task

我们前面说了开启一个线程有 2 种方式:new Thread 和 new Runnable 。但是,由于 Runnable 类的 run 方法是 void 类型的,没有返回值:

1
2
3
public interface Runnable {
void run();
}

因此 Java 中给准备了 Callable 这样的类,让方法有返回值:

1
2
3
public interface Callable<V> {
V call() throws Exception;
}

由于Thread 中只能接收 Runnable 参数,因此 Callable 不能直接传入使用,因此就有了 Task 这种类型,可能课程中的这张图更形象一点:

Runnable和Callable和Future

关于 Callable 的使用,我们可以参考下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CallableDemo {

private static class UseCallable implements Callable<Integer>{
private int sum;

@Override
public Integer call() throws Exception {
System.out.println("Callable 子线程开始计算");
for (int i = 0;i<5000;i++) {
sum += i;
}
System.out.println("Callable 子线程计算结束,结果:" + sum);
return sum;
}
}

public static void main(String[] args) throws Exception{
UseCallable useCallable = new UseCallable();
FutureTask<Integer> task = new FutureTask<>(useCallable);
new Thread(task).start();
System.out.println("main 方法中获取结果:" + task.get());
}
}

不过,需要注意的是,我们如果想取消 Task ,可以直接调用 task.cancel(true); ,其中传入的 true 是中断线程的标志,前面章节有说我们不建议使用 volatile 类型的变量去停止线程,还是希望使用 interrupt 去停止。所以,我们光调用 task.cancel(true);还不行,还得在线程中配合中断操作,如下所示:

1
2
3
4
5
6
7
8
9
@Override
public Integer call() throws Exception {
for (int i = 0;i<5000;i++) {
if (Thread.currentThread().isInterrupted()) {
return null;
}
//删除无关代码
}
}

一、错误加锁的问题

Java 的 Object 类中有 hashCode() 方法,它是用对象在内存中的地址来产生这个哈希值,当然,我们可以重写 hashCode() 方法。如果在重写了 hashCode() 方法之后还想获取到这个由内存中地址产生的哈希值,可以使用 identityHashCode() 方法。

在使用 synchronized 关键字去做同步的时候,要确保 synchronized 里面的对象是同一个对象,否则会达不到效果,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static void main(String[] args) {
Work work = new Work(1);
for (int i = 0; i < 5; i++) {
new Thread(work).start();
}
}

static class Work implements Runnable {
Integer i;

public Work(Integer integer) {
i = integer;
}

@Override
public void run() {
synchronized (i) {
i++;
System.out.println("当前 i = " + i);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

本来预期输出 2~6 的值,但是这段代码输出的数字是不可控的。这是为什么呢?问题就在 i ++ 上, 反编译可以看到, i ++ 之后就会生成新的 Integer 对象,这样一来,导致后续的程序获取的 Integer 就不是同一个对象了。

改进方法可以是自己创建一个 Object obj = new Object() ;因为这个对象不会改变,所以我们可以用它来作为锁对象。

1
2
3
4
5
6
7
8
9
10
static class Work implements Runnable {

Object obj = new Object();
@Override
public void run() {
synchronized (obj) {
//省略代码。。。
}
}
}

二、volatile 关键字

比较简单,略

三、ThreadLocal 使用

Spring 在事务中用到了 ThreadLocal ,为什么要这么做?这是因为我们想要为每个线程保存自己的数据库 connection (连接)。这样比较容易控制事务的边界。

关于 ThreadLocal 需要注意的有 2 点:

  • ThreadLocal 能够给初始值

  • 每个线程单独操作自己的线程中的ThreadLocalMap ,所以压根不会有冲突

1
2
3
4
5
6
7
//ThreadLocal 给初始值
private static ThreadLocal<Integer> num = new ThreadLocal<>(){
@Override
protected Integer initialValue() {
return 1;
}
};

四、ThreadLocal 的实现

如果要理解 ThreadLocal 的原理,就从它最简单的 get() 方法开始看(以下代码是基于我本地的 JDK 20 ,不过和前面版本相差也不太大):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public T get() {
//Thread.currentThread() 表示当前线程
return get(Thread.currentThread());
}

private T get(Thread t) {
//根据当前线程,获得 ThreadLocalMap 对象
ThreadLocal.ThreadLocalMap map = getMap(t);
//。。。 省略无关代码

//这里getEntry 传入了 this ,也就是以 ThreadLocal 本身为 key
ThreadLocal.ThreadLocalMap.Entry e = map.getEntry(this);
T result = (T) e.value;
return result;
}

也就是说,首先根据当前线程的 thread 对象 获得 ThreadLocalMap 对象 map ,之后,以ThreadLocal 自身(那个this) 为 key ,从 map 中取出 value ,这个 value 就是我们想要的值了,来看下 getMap 的实现:

1
2
3
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

这里直接返回了 Thread.threadLocals,这意味着 ThreadLocalMap 类型的 map 是 Thread 的成员变量 !那就没什么好说的了,每个 thread 对象自己都有这么个成员变量,每次操作的时候都是对线程对象 thread 自己的 ThreadLocalMap 类型的成员变量 threadLocals 进行操作,当然不会有线程问题了。

这里还有个问题需要澄清,就是如果定义了多个 ThreadLocal 对象 threadLocal1、threadLocal2… ,那么又是怎么保存的呢?我们来看下 ThreadLocal 的内部类 ThreadLocalMap 就知道了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ThreadLocal<T> {
// 。。。 省略无关代码
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

//The initial capacity -- MUST be a power of two.
private static final int INITIAL_CAPACITY = 16;

private ThreadLocal.ThreadLocalMap.Entry[] table;
}
// 。。。 省略无关代码
}

ThreadLocal 中有个 ThreadLocalMap 类,并且 ThreadLocalMap 中有个 Entry [] 数组类型的 table ,用来存放所有的 Entry 。

这里有个细节, Entry [] 数组的初始大小是16,并且官方建议这个值应该是 2^n 这样的值

上面看到 ThreadLocal 的 get() 方法最终会调用 ThreadLocalMap 的 getEntry 方法,看ThreadLocalMap.getEntry 应该更能理解:

1
2
3
4
5
6
7
8
9
10
11
12
# ThreadLocalMap 类
private ThreadLocal.ThreadLocalMap.Entry getEntry(ThreadLocal<?> key) {
//根据 key 获取 在数组 table 中的index
int i = key.threadLocalHashCode & (table.length - 1);
//根据 index 获取 table[index]
ThreadLocal.ThreadLocalMap.Entry e = table[i];
//处理可能的 hash 冲突和空值
if (e != null && e.refersTo(key))
return e;
else
return getEntryAfterMiss(key, i, e);
}

看了 get() 方法之后,ThreadLocal 的 set 的逻辑应该也能很容易理解了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# java.lang.ThreadLocal 

public void set(T value) {
//将当前线程传过去
set(Thread.currentThread(), value);
}

private void set(Thread t, T value) {
//根据当前线程获取它的 ThreadLocalMap 对象
ThreadLocal.ThreadLocalMap map = getMap(t);
//。。。省略无关代码
if (map != null) {
//ThreadLocalMap 对象不空,调用其 set 方法,以ThreadLocal 本身作为 key
map.set(this, value);
} else {
//如果 ThreadLocalMap 对象还是空的,就创建个
createMap(t, value);
}
}

最后调用到 ThreadLocalMap 的 set 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void set(ThreadLocal<?> key, Object value) {
//前面说的 Entry[] 数组
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
//通过某种方式获取到即将插在数组中的位置 i
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

//省略无关代码。。。
//可能位置 i 会有冲突,就 nextIndex,也有可能直接覆盖之前的值
//nextIndex 其实就是 + 1 操作,如果超出了数组长度,就又从 0 开始

//创建 Entry 对象,存入value,然后放入 tab[i] 中
tab[i] = new ThreadLocal.ThreadLocalMap.Entry(key, value);

//省略无关代码。。。
}

就是经过某种方法计算出在数组中的位置 i (可能会有位置冲突,就需要重新计算,或者之前有设置过了,就需要覆盖),然后创建 Entry 实体,存入 table[i] 中。

经过以上代码就整个流程能够串起来了:

  1. ThreadLocalMap 是 ThreadLocal 的内部类,ThreadLocalMap 中有一个 Entry[] 数组,用于存放(该线程)所有使用 ThreadLocal 存放的值(以 ThreadLocal 对象本身为key ,得出 index, 再根据 index 从 Entry 数组中取值)

  2. 每个线程对象 thread 都有一个 ThreadLocalMap 对象 threadLocals

  3. 当创建一个 ThreadLocal 对象,调用其 set 方法时,首先检测当前线程是否存在threadLocals,不存在就创建

  4. 然后以 ThreadLocal 对象本身为 key ,设置的值为 value ,存入ThreadLocalMap 中的 Entry[] 数组中(会有冲突处理过程)

  5. 当从某个线程中 get 某个 ThreadLocal 对象的值时,首先获取到该线程,然后取其 threadLocals 对象

  6. 之后以 ThreadLocal 对象本身作为 key ,调用 threadLocals 对象的 getEntry 方法

  7. 根据 key 计算该 Entry 在 table 数组中可能的位置 index ,这个 index 可能会冲突, 如果冲突了就会做前面说的 nextIndex 操作(其实就是 + 1 操作,如果超出了数组长度,就又从 0 开始),重新获取新的 index 再次尝试

  8. 最后,通过获取的 index 从 Entry[] 数组类型的变量 table 中获取 Entry: Entry entry = table[index]

五、ThreadLocal 可能造成内存泄漏

ThreadLocal 用不好会造成内存泄露。

在使用完后,需要调用remove方法将其Entry移除,否则会导致内存泄露,直到线程对象 thread 被销毁才能被回收。首先看下 ThreadLocal 的原理图 :

ThreadLocal内存泄漏示意图

结合源码我们知道,Entry类是继承WeakReference的,当 ThreadLocal 对象没有被引用之后,可能就被回收了,所以 Entry 中的key就成为了 null ,不可能再被使用到了。

但是,由于我们没有调用 remove 方法(事实上,get和set方法调用过程也可能触发清理 key 为 null 的 Entry),这个 Entry 实例还是存在的,虽然 key 没有了,value 还是在的,所以造成了泄露。

在ThreadLocal 新增或者删除一个元素的时候,会触发这种 key 为 null 的 Entry 扫描操作,这也是不断添加 ThreadLocal 对象并不断移除这些 ThreadLocal 对象时查看内存变化会维持在一个不高不低的阶段的原因。

六、ThreadLocal 可能引发线程不安全

当 ThreadLocal 中使用的是一个静态变量时,可能产生线程不安全的情况,因为各个线程大家的value都是指向同一份引用,比如如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class ThreadLocalUnsafe implements Runnable{

static Number number = new Number(0);

@Override
public void run() {
number.value = number.value + 1;
threadLocal.set(number);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}

System.out.println(Thread.currentThread().getName() + "中 integer 的值 为:" + threadLocal.get().value);

}

public static ThreadLocal<Number> threadLocal = new ThreadLocal<>();

public static void main(String[] args) {

for (int i = 0 ;i<5;i++) {
new Thread(new ThreadLocalUnsafe()).start();
}
}

static class Number {
public int value;
public Number(int num){
this.value = num;
}
}
}

在我运行后,输出全部是 5 :

Thread-4中 integer 的值 为:5
Thread-0中 integer 的值 为:5
Thread-3中 integer 的值 为:5
Thread-2中 integer 的值 为:5
Thread-1中 integer 的值 为:5

一般来说,number 初始值是 0 ,每个线程给它 + 1 操作后存入 ThreadLocal 中,后续通过 threadLocal 取出来的时候值应该不一样才对,但是我们看到的是各个线程的值都是一样的。这是因为 ThreadLocal 在存入 value 的时候,存入的都是 number 这同一个对象,所以只要一处改了,就到处都改了,这种情况要注意。

七、线程配合

wait 和 notify 都要在同步代码块中使用,他们有标准的使用范式,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
//wait 过程
synchronized (对象) {
while (条件不满足) {
对象.wait();
}
//业务逻辑
}

//notify 过程
synchronized (对象) {
//业务逻辑改变条件
对象.notifyAll();
}

wait在休眠之前就会释放锁,而notify 与notifyAll不会,他调用了之后,还要等同步区间执行完了,才释放锁。

范式中间为什么要用while循环而不是if判断条件?

如果对象执行 notifyAll ,那么很多线程都会被唤醒,比如是 5 个线程在等待打印机,当某个线程打印完成 notifyAll 的时候,这 5 个线程都被唤醒,由于是使用 if 语句判断的,唤醒之后直接执行后续的代码了,即业务逻辑(打印)了,此时就会出问题。但是如果是while 循环的话,唤醒之后还会判断条件是否满足(打印机是否被别人占了),这样逻辑才正确。

八、等待超时处理

线程等待超时处理,比如使用池化技术的时候,我们去从 pool 中获取资源,但是会有超时提醒,这时候就能用 wait/notify 去做这样的事情。视频中的例子主要需要注意 remain 时长计算,因为唤醒一次就要重新计算等待时长,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public Connection fetchConnection(long mills) throws InterruptedException {
synchronized (pool) {
if (mills < 0) {//永不超时
while(pool.isEmpty()) {
wait();
}
return pool.removeFirst();
} else {
//超时时刻
long future = System.currentTimeMillis() + mills;
//等待时长
long remain = mills;
while(pool.isEmpty() && remain > 0) {
wait(remain);
//重新计算等待时长
remain = future - System.currentTimeMillis();
}
Connection connection = null;
if (!pool.isEmpty()) {
connection = pool.removeFirst();
}
return connection;
}
}
}

为什么需要重新计算?因为唤醒后可能抢不到锁,还会在while中接着等待。