6.讲一下java的垃圾回收机制
Java 的垃圾回收机制,也就是我们常说的 GC (Garbage Collection),是 Java 虚拟机 (JVM) 提供的一项自动内存管理功能。它的核心目标是自动找出内存中不再被任何程序引用的对象,并回收它们所占用的空间,从而避免内存泄漏,也让开发者从繁琐的手动内存管理中解放出来。
我的理解主要包含以下几个方面:
1. 如何判断对象是“垃圾”?
目前主流的 JVM 采用的是可达性分析算法 (Reachability Analysis)。
- 基本思想:这个算法会从一系列被称为 “GC Roots” 的根对象开始,沿着引用链进行遍历。如果一个对象能够从任何一个 GC Root 通过引用链追溯到,那么这个对象就是“存活”的,否则,它就是“垃圾”,可以被回收。
- 哪些是 GC Roots:可以简单理解为,是程序当前肯定不能被回收的对象。主要包括:
- Java 虚拟机栈中引用的对象(比如方法的局部变量)。
- 方法区中类的静态属性引用的对象。
- 本地方法栈中 JNI (Java Native Interface) 引用的对象。
- 被用作同步锁 (
synchronized) 的对象。
2. 在哪里进行回收?(JVM 堆内存划分)
为了提高回收效率,JVM 的 HotSpot 实现(包括 Android 的 ART 虚拟机)采用了分代收集的思想,将堆内存划分成了几个区域:
-
新生代 (Young Generation):
- 绝大多数新创建的对象都存放在这里。新生代内部又分为一个 Eden 区和两个 Survivor 区(通常称为 S0 和 S1)。
- 新对象首先进入 Eden 区。当 Eden 区满了,就会触发一次 Minor GC。在 Minor GC 中,存活的对象会被复制到其中一个空的 Survivor 区(比如 S0),然后 Eden 区被清空。
- 下次 Minor GC 时,Eden 区和 S0 区中存活的对象,会被一起复制到另一个空的 Survivor 区(S1)。每次复制,对象的“年龄”会加 1。
-
老年代 (Old Generation):
- 当一个对象在新生代中经过多次 Minor GC 依然存活(默认是 15 次),它就会被“晋升”到老年代。
- 老年代存放的都是生命周期较长的对象。当老年代的空间满了,就会触发一次 Major GC 或 Full GC,这个过程通常比 Minor GC 慢得多,并且可能会导致较长时间的“Stop-The-World”,即暂停所有用户线程。
3. 用什么算法进行回收?
针对不同分代的特点,JVM 会采用不同的回收算法:
-
标记-复制 (Mark-Copy) 算法:
- 过程:将内存分为两块相等的空间,每次只使用其中一块。当这一块用完了,就将还存活的对象复制到另一块上面,然后把已使用过的空间一次性清理掉。
- 应用:主要用于新生代。因为新生代中大部分对象都是“朝生夕死”的,存活对象很少,所以复制成本低,效率高,而且不会产生内存碎片。Survivor 区就是这种算法的典型应用。
-
标记-清除 (Mark-Sweep) 算法:
- 过程:分为“标记”和“清除”两个阶段。首先标记出所有需要回收的对象,然后统一回收所有被标记的对象。
- 缺点:会产生大量的内存碎片,导致后续可能没有足够大的连续空间来分配给大对象。
-
标记-整理 (Mark-Compact) 算法:
- 过程:标记过程与“标记-清除”一样,但在后续步骤中,它不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
- 应用:主要用于老年代。因为它解决了内存碎片的问题,但由于需要移动对象,成本较高。
总结
Java 的 GC 机制是一个复杂的自动化系统。它通过可达性分析来识别垃圾,通过分代模型来优化回收效率,并结合使用标记-复制、标记-清除、标记-整理等多种算法来完成不同区域的垃圾回收。在 Android 中,ART 虚拟机的 GC 机制也是基于这些核心思想进行深度优化的,旨在减少卡顿,提升应用流畅度。
9。讲一下handler机制
Android 的 Handler 机制是系统提供的一套用于线程间通信的核心解决方案。它的主要应用场景是,允许我们在一个子线程(或称工作线程)中发送一个任务,然后在另一个线程(通常是主线程/UI 线程)中去执行这个任务,从而实现异步更新 UI 等操作,避免了在子线程直接操作 UI 导致的 CalledFromWrongThreadException 异常。
这套机制的核心由四个组件构成:Handler、Message、MessageQueue 和 Looper。
1. 四大核心组件
-
Message(消息):它是线程间需要传递的数据和任务的载体。我们可以把它看作一封“信”,信中可以携带少量int型数据(arg1,arg2),一个Object对象,以及一个Runnable任务。为了节省内存,我们通常通过Message.obtain()从一个全局的消息池中获取实例,而不是直接new Message()。 -
MessageQueue(消息队列):它的内部数据结构是一个单向链表,负责存储由Handler发送过来的所有Message对象。它按照先进先出(FIFO)的原则对消息进行管理,但也可以通过设置延迟时间来实现延时消息,此时它会按触发时间的先后顺序来排列。 -
Handler(处理者):它是我们最直接接触的“API 入口”。它的主要职责有两个:- 发送消息:通过调用
sendMessage()或post()等一系列方法,将一个Message或Runnable任务放入到MessageQueue中。 - 处理消息:重写
handleMessage()方法,当Looper从MessageQueue中取出属于这个Handler的消息时,这个方法就会被回调,我们在这里执行具体的 UI 更新等操作。
- 发送消息:通过调用
-
Looper(循环器):它是整个机制的“心脏”和“发动机”。每个线程最多只能有一个Looper。它的核心方法是loop(),一旦调用,Looper就会进入一个无限循环,不断地从与自己绑定的MessageQueue中检查并取出消息。如果有消息,就把它分发给对应的Handler去处理;如果队列为空,它就会阻塞等待,让出 CPU 资源。
2. 整体工作流程
整个流程可以概括为:
-
准备阶段:在主线程中,系统已经默认创建了
Looper和MessageQueue。所以我们直接创建Handler实例即可。这个Handler在创建时会自动关联到当前线程(主线程)的Looper和MessageQueue。 -
发送消息:在子线程中,我们使用主线程创建的那个
Handler实例,调用sendMessage()或post()方法。 -
入队:
Handler会将这个Message对象放入到它所关联的MessageQueue中进行排队。 -
循环与出队:运行在主线程的
Looper在它的loop()方法里,时刻不停地监视着MessageQueue。一旦发现有新的消息,就会把它从队列中取出来。 -
分发与处理:
Looper将取出的消息,交给这个消息的目标Handler(msg.target),调用其dispatchMessage()方法。这个方法最终会回调到我们重写的handleMessage()方法。 -
执行任务:
handleMessage()方法最终在主线程中被执行,我们就可以在这里安全地更新 UI 了。
核心要点:ThreadLocal
Looper 是如何保证每个线程只拥有一个实例,并且与当前线程绑定的呢?答案是 ThreadLocal。Looper 被存储在一个 ThreadLocal 对象中,ThreadLocal 能够为每个使用该变量的线程都提供一个独立的变量副本,从而实现了线程的隔离。这也是为什么我们在子线程中想使用 Handler 机制时,必须手动调用 Looper.prepare() 来创建 Looper,并通过 Looper.loop() 来开启循环的原因。
10.内存泄露 内存溢出的概念
1. 内存泄漏 (Memory Leak)
- 概念:内存泄漏指的是,程序中一些不再需要使用的对象,由于仍然被其他正在使用的对象所引用,导致垃圾回收器 (GC) 无法识别并回收它们。
- 核心思想:可以通俗地理解为“该回收的没回收”。这个对象在逻辑上已经是“垃圾”了,但由于还存在引用链,GC 认为它还是“存活”的。
- 影响:少量的内存泄漏可能不会立即产生影响,但它会像一个慢性的病,随着时间的推移,泄漏的对象越积越多,持续占用宝贵的内存资源。这会导致可用内存越来越少,GC 越来越频繁,应用性能下降,最终可能引发内存溢出。
- 在 Android 中的典型例子:
- 一个生命周期很长的对象(比如单例或静态变量)持有了生命周期很短的对象(比如
Activity或Fragment)的引用。当Activity退出后,由于单例还引用着它,导致这个Activity及其关联的所有 View 和资源都无法被回收。
- 一个生命周期很长的对象(比如单例或静态变量)持有了生命周期很短的对象(比如
2. 内存溢出 (Memory Overflow / OutOfMemoryError, OOM)
- 概念:内存溢出指的是,程序在向系统申请内存时,系统没有足够大的连续内存空间分配给它,从而抛出
OutOfMemoryError错误,导致程序崩溃。 - 核心思想:可以通俗地理解为“想申请的申请不到”。这表示程序的需求超出了当前 JVM 或 ART 虚拟机所能提供的内存上限。
- 产生原因:
- 内存泄漏的累积:这是最常见的原因。内存泄漏不断地蚕食内存,最终把所有可用内存都耗尽了。
- 一次性加载过大的数据:比如一次性从数据库查询大量数据到内存中,或者加载一张分辨率极高、体积巨大的图片,直接超出了单次分配的限制。
- 创建了过多的对象:在一个循环中无节制地创建大量对象,即使每个对象都不大,累积起来也会很快耗尽内存。
11.一个activity用一个handlerpost去delay一个消息,比如说五分钟吧,接着吧界面关闭,因为消息没有移除会发生一个内存泄露,这个情况下 activity到它的gcrooter的引用链路是什么
这个场景确实是使用 Handler 时最典型的一种内存泄漏。当 Activity 关闭后,由于一个延迟消息还存在于主线程的 MessageQueue 中,导致 Activity 实例无法被垃圾回收器回收。
其根本原因在于一条从 GC Root 到 Activity 实例的强引用链依然存在。这条引用链具体是这样的:
-
GC Root (主线程) ->
Looper- 在 Android 应用中,主线程(
ActivityThread)是一个活跃的线程,因此它本身就是一个 GC Root。每个线程通过ThreadLocal存储着一个Looper对象。只要主线程在运行,它就始终持有对主线程Looper的引用。
- 在 Android 应用中,主线程(
-
Looper->MessageQueueLooper对象持有其对应的MessageQueue(消息队列) 的引用。这是Looper能够不断从中取出消息的前提。
-
MessageQueue->MessageMessageQueue内部通过一个链表结构持有所有待处理的Message(消息) 对象。我们那个延迟了 5 分钟的Message此时就静静地躺在这个队列中,等待被处理。
-
Message->Handler- 每个
Message对象都持有一个target字段,这个字段引用了创建并发送它的那个Handler实例。这样Looper在取出消息后,才知道应该把这个消息交给哪个Handler去处理。
- 每个
-
Handler->Activity- 这是最关键的一步。在
Activity中,我们通常会把Handler定义为一个非静态内部类或匿名内部类。根据 Java 的语法特性,非静态内部类会隐式地持有其外部类(也就是Activity)的引用。所以,Handler实例内部就包含了一个指向Activity实例的引用。
- 这是最关键的一步。在
所以,整条引用链串联起来就是:
主线程 (GC Root) -> Looper -> MessageQueue -> Message -> Handler -> Activity
因为这条引用链的存在,尽管 Activity 的 onDestroy 方法已经被调用,界面也已经关闭,但在 GC 看来,这个 Activity 对象依然是“可达的”,所以它不会被回收。只有等到 5 分钟后,这个 Message 被 Looper 处理完毕并从 MessageQueue 中移除,这条引用链才会断开,Activity 才最终有机会被回收。但在这 5 分钟内,内存泄漏是确实发生了。
解决方案: 核心思想就是在 Activity 销毁时,切断这条引用链。我们通常在 Activity 的 onDestroy 方法中调用 handler.removeCallbacksAndMessages(null),这会主动将队列中所有与这个 handler 相关的 Message 都移除,从而立即打破引用链,避免内存泄漏。
12.最后的gcrooter是谁
。在上一题我们讨论的 Activity -> Handler 内存泄漏的引用链中,最终的 GC Root 就是主线程(Main Thread)本身。
具体来说,是主线程这个 Thread 对象。
我们可以这样理解:
-
活跃的线程是 GC Root:在 JVM 和 ART 的垃圾回收机制中,一个正在运行的、活跃的线程本身就是一个根节点(GC Root)。因为线程需要执行代码,它栈上的所有局部变量、操作数等都必须是存活的,所以线程对象以及它执行上下文中的所有引用,都是可达性分析的起点。
-
主线程的特殊性:Android 应用的进程启动时,系统会创建一个主线程(也叫 UI 线程)。这个线程负责应用的整个生命周期,运行着一个
Looper来处理所有的 UI 事件、广播等。只要我们的应用还在运行,主线程就永远是活跃的。 -
引用链的源头:我们之前分析的引用链
主线程 -> Looper -> MessageQueue -> Message -> Handler -> Activity,它的起点就是这个永远活跃的主线程。主线程通过ThreadLocal机制持有对自身Looper实例的引用。所以,只要主线程不死,它就牢牢地抓着Looper,Looper抓着MessageQueue,以此类推,最终导致Activity无法被回收。
总结一下:这条内存泄漏引用链的“万恶之源”,就是那个贯穿应用整个生命周期的、永远在运行的“主线程”对象。它作为一个最顶层的 GC Root,开启了这条强引用链。
13.有一个客户端和服务端,客户端给服务端发一个hello,你从网络模型角度,每一层要做哪些操作。
当一个客户端向服务端发送“hello”时,这个简单的动作会贯穿整个网络模型。我会以主流的 TCP/IP 五层模型(物理层、数据链路层、网络层、传输层、应用层)为例,从客户端发出到服务端接收,逐层讲解它们的操作。
这个过程的核心是数据的封装和解封装。
客户端:发送 “hello”(数据封装过程)
在客户端,数据是从顶层(应用层)开始,逐层向下传递,每一层都会给数据包加上自己的“头部信息”,这个过程就像是层层打包。
-
应用层 (Application Layer):
- 操作:这是我们编写的应用程序所在的层。我们的代码首先创建了 “hello” 这个字符串。为了让网络传输,通常会把它放入一个符合特定应用协议的数据包中,比如一个 HTTP 请求体。
- 结果:数据变成了类似
[HTTP Header | "hello"]的格式。
-
传输层 (Transport Layer):
- 操作:应用层的数据包被传递到这一层。它的主要任务是建立端到端的连接。它会选择一个传输协议,对于需要可靠传输的“hello”,通常会选择 TCP。
- 封装:它会在数据包前面加上一个 TCP 头部,这个头部里包含了源端口号(客户端随机分配的一个端口)和目标端口号(比如 HTTP 服务的 80 端口或 HTTPS 的 443 端口)。
- 结果:数据包变成了
[TCP Header | [HTTP Header | "hello"]]。我们称这个单元为“段 (Segment)”。
-
网络层 (Network Layer):
- 操作:传输层的数据段被传递到这一层。它的核心任务是提供逻辑地址并选择路由。
- 封装:它会加上一个 IP 头部,里面包含了源 IP 地址(客户端的 IP)和目标 IP 地址(服务端的 IP)。
- 结果:数据包变成了
[IP Header | [TCP Header | ...]]。我们称这个单元为“包 (Packet)”。
-
数据链路层 (Data Link Layer):
- 操作:网络层的包被传递到这一层。它负责在相邻的两个网络节点间传输数据。
- 封装:它会加上一个帧头和帧尾。帧头里包含了源 MAC 地址(客户端网卡的物理地址)和目标 MAC 地址(下一跳设备,比如路由器的 MAC 地址)。帧尾则包含了用于校验数据完整性的信息。
- 结果:数据包变成了
[Frame Header | [IP Header | ... ] | Frame Tail]。我们称这个单元为“帧 (Frame)”。
-
物理层 (Physical Layer):
- 操作:这是最底层,负责物理传输。它将数据链路层的帧,转换成二进制的电信号或光信号,通过网线、光纤或无线电波等物理媒介发送出去。
服务端:接收 “hello”(数据解封装过程)
在服务端,数据流向正好相反,是从底层开始,逐层向上传递,每一层都会拆开并解析对应层的“头部信息”,这个过程就像是层层拆包。
-
物理层:
- 操作:服务端的网卡接收到电信号或光信号,并将其转换回二进制数据,还原成一个完整的数据帧。
-
数据链路层:
- 操作:它接收到数据帧,首先检查帧尾的校验信息,确认数据在传输中没有损坏。然后,它会解析帧头,看到目标 MAC 地址是自己,于是就“签收”了这个包裹,并将头尾拆掉,把里面的 IP 包取出来,向上传递给网络层。
-
网络层:
- 操作:它拿到 IP 包,解析 IP 头部,发现目标 IP 地址是自己,于是也“签收”了。然后它拆掉 IP 头部,把里面的 TCP 段取出来,根据 TCP 头部里的信息,传递给传输层。
-
传输层:
- 操作:它拿到 TCP 段,解析 TCP 头部,根据目标端口号,找到正在监听这个端口的应用程序(比如我们的 Web 服务器)。然后它拆掉 TCP 头部,把最原始的应用层数据(即
[HTTP Header | "hello"])取出来,交给对应的应用程序。
- 操作:它拿到 TCP 段,解析 TCP 头部,根据目标端口号,找到正在监听这个端口的应用程序(比如我们的 Web 服务器)。然后它拆掉 TCP 头部,把最原始的应用层数据(即
-
应用层:
- 操作:我们的服务器应用程序最终收到了这个数据包。它解析 HTTP 协议,从中取出了最核心的数据——“hello” 字符串。至此,整个通信过程完成。
14.hashmap的有优点是什么,如何实现的优点
HashMap 最核心的优点是它提供了极高的增、删、改、查性能,在理想情况下,这些操作的平均时间复杂度都能达到 O(1)。
这个卓越的性能优点,是通过其精巧的内部数据结构和算法实现的,主要依赖于**哈希表(Hash Table)**这种结构。
我可以从以下几个方面来讲解它是如何实现这个优点的:
1. 核心结构:数组 + 链表 / 红黑树
HashMap 的内部维护了一个数组(在源码中通常叫做 table),这个数组的每一个位置我们称之为“桶 (bucket)”。这个数组是实现 O(1) 性能的物理基础。
当我们要存入一个键值对 (key, value) 时,HashMap 并不是简单地把它放到数组的某个位置,而是通过一个“哈希”过程来计算出它应该放在哪个桶里。
2. 实现 O(1) 的关键步骤:put(key, value) 过程
-
计算哈希值:首先,
HashMap会调用key对象的hashCode()方法,得到一个原始的哈希码。 -
扰动与寻址:为了让键值对在数组中分布得更均匀,减少“哈希冲突”,
HashMap会对原始哈希码进行一次“扰动”计算(高位异或低位),得到一个更优的哈希值。然后,它用这个哈希值和数组的长度进行位运算(通常是(n-1) & hash),最终计算出一个数组的索引 (index)。 -
直接定位:这个计算索引的过程非常快。一旦索引确定,
HashMap就可以像操作普通数组一样,直接定位到那个桶。这就是它能够实现 O(1) 性能的根本原因——它把复杂的查找问题,转换成了一个简单的数学计算问题。
3. 如何处理“哈希冲突”?
当然,不同的 key 可能会计算出相同的数组索引,这就是“哈希冲突”。HashMap 有一套成熟的机制来解决这个问题:
-
拉链法 (Chaining):当发生冲突时,
HashMap会在那个桶的位置形成一个链表。所有计算到同一个索引的键值对,都会以节点的形式串在这个链表上。当需要查找一个key时,会先通过哈希计算定位到桶,然后遍历这个短链表,通过key的equals()方法找到正确的节点。 -
红黑树优化 (JDK 1.8+):为了防止极端情况下(比如所有
key的哈希值都一样)链表变得过长,导致查询性能退化到 O(n),从 JDK 1.8 开始,HashMap引入了优化:当一个桶中的链表长度超过一个阈值(默认为 8),并且数组的总长度也大于一个阈值(默认为 64)时,这个链表就会被自动转换成一棵红黑树。红黑树是一种自平衡的二叉查找树,它的查询性能是 O(log n),远优于链表的 O(n)。
总结
HashMap 的优点是极高的存取效率,它是这样实现的:
- 根本上,通过哈希算法,将键值对的存储位置映射到一个大数组的索引上,实现了 O(1) 的寻址速度。
- 其次,通过拉链法(链表)优雅地解决了哈希冲突问题。
- 最后,通过在 JDK 1.8 引入红黑树,优化了极端冲突情况下的性能,保证了最坏情况下的效率不会太差。
正是这套“数组定位为主,链表/红黑树解决冲突为辅”的组合拳,共同造就了 HashMap 强大的性能优点。
8.java怎么避免空异常?
空指针异常(NullPointerException)是 Java 开发中最常见的运行时异常,避免它也是编写健壮、高质量代码的基本要求。在我的实践中,我会从以下几个层面来主动避免和防御空指针异常:
1. 事前预防与编码规范 (Defensive Programming)
这是最基础也是最重要的层面,养成良好的编码习惯。
-
明确的空检查:在使用任何可能为
null的对象之前,进行显式的if (obj != null)判断。这是最直接、最通用的方法。 -
常量与变量的比较顺序:在调用对象的
equals方法时,总是将确定的、不会为 null 的值放在前面。比如,使用"hello".equals(variable)而不是variable.equals("hello")。这样即使variable是null,程序也不会抛出异常,只会返回false。 -
谨慎使用自动拆箱:当一个包装类型(如
Integer)的变量为null时,如果直接将它赋值给一个基本类型(如int),编译器会自动进行拆箱操作,这会立即导致空指针异常。所以在使用包装类型时,要特别注意其可能为null的情况。
2. 利用现代 Java 提供的工具 (Modern Java Features)
从 Java 7 和 8 开始,语言层面提供了更优雅的工具来处理 null。
-
使用
java.util.Objects工具类:对于方法的参数校验,我倾向于使用Objects.requireNonNull()。这个方法可以在对象为null时,立即抛出一个带有明确信息的NullPointerException。这遵循了“快速失败”(Fail-Fast)的原则,能让我们在问题发生的第一时间就定位到错误,而不是让一个null值在系统中传递很久后才在某个意想不到的地方爆炸。 -
拥抱
java.util.Optional(Java 8+):这是我个人非常推崇的方式。Optional是一个容器类,它可以代表一个值存在或不存在。- 作为返回值:当一个方法的返回值可能为
null时,我更倾向于返回一个Optional对象。这就在方法签名层面明确地告诉调用者:“这个结果可能没有值,你必须处理这种情况”。 - 处理方式:调用者可以通过
isPresent()、ifPresent()、orElse()、orElseGet()等方法,以一种链式、函数式的风格来安全地处理可能为空的情况,代码更优雅,意图也更清晰。
- 作为返回值:当一个方法的返回值可能为
3. 借助注解和静态分析工具 (Annotations & Static Analysis)
- 使用
@NonNull和@Nullable注解:在 Android 开发中,我们可以使用 Support Library 或 JetBrains 提供的注解。在方法的参数、返回值或类的字段上标记@NonNull或@Nullable。- 作用:这些注解本身在运行时不会起作用,但它们可以被 Android Studio 的静态代码分析工具和编译时检查工具(如 Lint)识别。如果你的代码违反了这些注解的约定(比如给一个标记为
@NonNull的参数传递了null),IDE 会立即给出警告,甚至在编译时报错。这能把问题扼杀在摇篮里。
- 作用:这些注解本身在运行时不会起作用,但它们可以被 Android Studio 的静态代码分析工具和编译时检查工具(如 Lint)识别。如果你的代码违反了这些注解的约定(比如给一个标记为
4. 合理的架构设计 (Architectural Design)
- 使用空对象模式 (Null Object Pattern):在某些场景下,可以定义一个“空对象”,这个对象具有和真实对象相同的接口,但所有方法的实现都是无害的空操作。当一个方法需要返回一个对象但又没有合适的真实对象时,就返回这个“空对象”实例,而不是
null。这样调用方就可以无需任何检查,直接调用方法,从而避免了大量的if (obj != null)判断。
总结一下,我会通过编码规范进行基础防御,利用 Optional 和 Objects 类写出更现代、更安全的代码,借助注解和静态分析工具在编码阶段就发现潜在问题,并结合设计模式从架构层面减少 null 的存在,多管齐下,最大限度地避免空指针异常。
11.kotlin是怎么预防空空异常的?
。Kotlin 在设计之初就将“空安全” (Null Safety) 作为一个核心特性,从语言的类型系统层面来根除空指针异常,而不是像 Java 那样依赖于开发者的编码习惯或外部工具。
Kotlin 的空安全机制,可以总结为以下几个核心要点:
1. 可空类型与非可空类型 (Nullable vs. Non-nullable Types)
这是 Kotlin 空安全的基石。在 Kotlin 中,所有的类型默认都是非可空的。
- 非可空类型:比如
var str: String = "hello"。你不能给这个str变量赋null值,任何尝试在编译时都会直接报错。这就杜绝了绝大多数空指针的来源。 - 可空类型:如果你希望一个变量可以持有
null,你必须在类型后面加上一个问号?来显式声明,比如var nullableStr: String? = null。
通过这种方式,Kotlin 强迫我们在编译阶段就必须想清楚一个对象到底允不允许为 null。
2. 安全调用操作符 (?.)
这是处理可空类型的最常用方式。
- 作用:它允许你在调用一个可空对象的属性或方法前,进行一次安全的检查。如果对象不为
null,就正常调用;如果对象为null,则整个表达式直接返回null,而不会抛出异常。 - 示例:
nullableStr?.length。如果nullableStr是null,这行代码的结果就是null,而不是抛出NullPointerException。这极大地简化了 Java 中if (obj != null)的模板代码。
3. Elvis 操作符 (?:)
这个操作符通常与安全调用符 ?. 配合使用,用于处理 null 时的默认值。
- 作用:它是一个三元运算符的简化版。
a ?: b的意思是,如果a不为null,就返回a的值;否则,就返回b的值。 - 示例:
val length = nullableStr?.length ?: 0。这行代码非常优雅地实现了:获取字符串的长度,如果字符串本身是null,那么长度就默认为 0。
4. 非空断言操作符 (!!)
Kotlin 也提供了一个“后门”,让你在明确知道一个可空对象在当前上下文中绝对不为 null 时,可以强制调用它。
- 作用:
nullableStr!!会将一个可空类型String?强制转换成非可空类型String。如果在运行时nullableStr恰好是null,那么这里会立即抛出NullPointerException。 - 使用场景:我只会在两种情况下使用它:一是在与老的 Java API 交互时,我能百分百确定返回值不为
null;二是在单元测试中,用于简化代码。在业务代码中,我倾向于避免使用它,因为它把空安全的责任又交还给了开发者。
5. 安全作用域函数 (?.let)
这是一个非常强大和地道的 Kotlin 写法,用于对非空对象执行一段代码。
- 作用:
nullableObj?.let { ... }会判断nullableObj是否为null。如果不为null,它就会执行let代码块,并且在这个代码块中,这个对象会变为非空类型(用it来引用)。 - 示例:
nullableStr?.let { str -> println("The length is ${str.length}") }。这保证了let内部的代码只有在nullableStr存在时才执行,并且str在这里是String类型,而不是String?。
总结一下,Kotlin 通过其类型系统,从根本上区分了可空与不可空,并提供了一套如 ?.、?:、?.let 等简洁而强大的工具链,让开发者能够以一种编译时安全的方式来优雅地处理 null 值,从而将 NullPointerException 从一个常见的运行时错误,变成了一个罕见的、通常是由于与 Java 交互或滥用 !! 导致的异常。
13.kotlin的空异常检查的是编译还是运行时候检查?
Kotlin 的空异常检查,其核心和绝大部分工作都发生在 编译时期 (Compile Time)。但为了与 Java 互操作以及提供一些灵活性,也存在一些特例会在 运行时期 (Runtime) 进行检查。
我可以从这两个方面来具体阐述:
1. 核心在编译时检查
这是 Kotlin 空安全最强大的地方。它通过类型系统,在代码编译阶段就杜绝了绝大多数空指针的可能性。
-
机制:当我们在代码中声明一个变量,比如
var str: String,Kotlin 的编译器就记录下来:“这个str变量是非空类型的”。之后,如果你尝试写str = null,或者将一个可空类型String?的值赋给它,编译器会立刻发现类型不匹配,直接抛出编译错误。你的代码甚至无法被成功编译成字节码。 -
结果:同样,如果你有一个可空类型的变量
var nullableStr: String?,并且你试图直接调用它的方法,如nullableStr.length,编译器会再次报错,强制你使用安全调用符?.或者其他安全的方式来处理。
所以,对于纯 Kotlin 代码,可以说 99% 的空安全检查都是在编译期完成的。 这意味着空指针异常从一个运行时“地雷”变成了一个编译时“语法错误”,开发者在写代码的时候就能发现并修复它。
2. 特例:运行时检查
尽管编译时检查是主流,但在以下两种特定场景,检查会发生在运行时:
-
使用非空断言操作符
!!- 机制:当我们使用
nullableStr!!时,我们其实是在告诉编译器:“我向你保证,这个值在运行时绝对不为null,请你信任我,不要再进行编译时检查了。” - 结果:编译器会听从你的指令,让你通过编译。但是,它会在生成的字节码中插入一个运行时的检查。当代码执行到这一行时,如果
nullableStr真的为null,那么程序就会在此时此地抛出一个NullPointerException。
- 机制:当我们使用
-
与 Java 代码交互 (平台类型)
- 机制:当 Kotlin 调用一个没有空安全注解的 Java 方法时,比如 Java 的
String getName(),Kotlin 编译器无法确定这个返回值是否可能为null。这时,它会把这个返回值的类型识别为一种特殊的“平台类型”,在 IDE 中通常表示为String!。 - 结果:平台类型很灵活,你可以把它当作可空类型
String?来安全处理,也可以直接当作非可空类型String来使用。如果你选择后者,比如val name: String = javaObject.getName(),编译器会信任你。但和!!类似,它可能会在背后插入一个运行时的断言。如果getName()真的返回了null,那么在赋值的这一刻,就会在运行时抛出异常。
- 机制:当 Kotlin 调用一个没有空安全注解的 Java 方法时,比如 Java 的
总结
一句话总结:Kotlin 的空安全检查是“以编译时检查为主,以运行时检查为辅”。
- 在纯 Kotlin 环境下,通过严格的类型系统,几乎所有空安全问题都在编译期被解决了。
- 只有在开发者主动选择“绕过”编译时检查(使用
!!操作符),或者在与不带空安全信息的 Java 代码交互时,检查的责任才会部分转移到运行时。
23.不同的线程池你知道这些怎么实现的吗?
24.线程池构造函数的参数的含义
关于线程池,核心在于理解 ThreadPoolExecutor 这个类,因为我们常用 Executors 工具类创建的几种典型线程池,本质上都是对 ThreadPoolExecutor 构造函数进行了不同的参数配置。
所以,我先来讲解一下 ThreadPoolExecutor 构造函数中七个核心参数的含义,理解了它们,也就自然明白了不同线程池的实现原理。
线程池构造函数的参数含义 (问题24)
ThreadPoolExecutor 的构造函数可以说是线程池的“灵魂”,它的七个参数精确地定义了线程池的工作行为:
-
corePoolSize(核心线程数):- 含义:这是线程池中长期保持存活的线程数量,即使它们处于空闲状态,也不会被回收(除非设置了
allowCoreThreadTimeOut)。可以理解为线程池的“常驻员工”。
- 含义:这是线程池中长期保持存活的线程数量,即使它们处于空闲状态,也不会被回收(除非设置了
-
maximumPoolSize(最大线程数):- 含义:当工作任务繁忙,核心线程都在忙,并且工作队列也满了的时候,线程池能够创建的线程数量上限。这部分超出核心数的线程可以理解为“临时工”。
-
keepAliveTime(保持存活时间):- 含义:指的是那些“临时工”(即
maximumPoolSize-corePoolSize这部分线程)在完成任务后,如果处于空闲状态,能够存活的最长时间。超过这个时间,它们就会被回收。
- 含义:指的是那些“临时工”(即
-
unit(时间单位):- 含义:为
keepAliveTime指定时间单位,比如秒 (SECONDS)、毫秒 (MILLISECONDS) 等。
- 含义:为
-
workQueue(工作队列):- 含义:一个阻塞队列,用于存放那些核心线程处理不过来,暂时无法执行的任务。新任务提交后,如果核心线程都在忙,任务就会被放入这个队列中排队。常见的队列有:
LinkedBlockingQueue:一个无界链表队列。使用它可能导致maximumPoolSize参数失效,因为任务会无限添加到队列中,不会触发创建“临时工”。ArrayBlockingQueue:一个有界数组队列,必须指定容量。SynchronousQueue:一个不存储元素的队列。每个插入操作必须等待一个移除操作,反之亦然。它会强制将任务直接交给线程处理。
- 含义:一个阻塞队列,用于存放那些核心线程处理不过来,暂时无法执行的任务。新任务提交后,如果核心线程都在忙,任务就会被放入这个队列中排队。常见的队列有:
-
threadFactory(线程工厂):- 含义:一个用于创建新线程的工厂。我们可以通过自定义
ThreadFactory来给线程池中的每个线程设置一个有意义的名字、设置成守护线程或者调整优先级等,这对于调试和问题排查非常有帮助。
- 含义:一个用于创建新线程的工厂。我们可以通过自定义
-
rejectedExecutionHandler(拒绝策略):- 含义:当线程池和工作队列都达到饱和状态(线程数达到
maximumPoolSize,队列也满了)时,新提交的任务该如何处理。默认有四种策略:AbortPolicy(默认):直接抛出RejectedExecutionException异常。CallerRunsPolicy:不使用线程池的线程,而是由提交任务的那个线程自己来执行这个任务。DiscardPolicy:直接悄悄地丢弃这个任务,不通知调用者。DiscardOldestPolicy:丢弃工作队列中最老的一个任务,然后尝试重新提交当前任务。
- 含义:当线程池和工作队列都达到饱和状态(线程数达到
不同线程池的实现原理 (问题23)
理解了上面的参数后,我们再来看 Executors 创建的几种常见线程池,就一目了然了。它们其实就是调用了 ThreadPoolExecutor 并传入了不同的参数组合:
-
newFixedThreadPool(int nThreads)(固定大小线程池)- 实现:
corePoolSize和maximumPoolSize都被设置为nThreads。keepAliveTime设置为 0。workQueue使用的是LinkedBlockingQueue(无界队列)。
- 特点:核心线程数和最大线程数相等,所以没有“临时工”,所有线程都是“常驻员工”。因为队列是无界的,所以再多的任务也只会排队,不会触发拒绝策略。这保证了并发线程数是固定的,适合需要控制并发资源消耗的场景。
- 实现:
-
newCachedThreadPool()(缓存线程池)- 实现:
corePoolSize设置为 0。maximumPoolSize设置为Integer.MAX_VALUE(几乎是无限大)。keepAliveTime设置为 60 秒。workQueue使用的是SynchronousQueue。
- 特点:没有核心线程。当新任务来了,
SynchronousQueue会迫使线程池立即寻找一个空闲线程来处理它,如果没有空闲线程,就会立即创建一个新线程。线程空闲 60 秒后会被回收。这种机制使得它非常灵活,适合处理大量、耗时短的突发任务。但风险是,如果任务提交速度过快,可能导致无限创建线程,耗尽系统资源。
- 实现:
-
newSingleThreadExecutor()(单线程线程池)- 实现:
corePoolSize和maximumPoolSize都被设置为 1。keepAliveTime设置为 0。workQueue使用的是LinkedBlockingQueue。
- 特点:本质上就是一个大小为 1 的
FixedThreadPool。它保证了所有任务都在同一个线程中,按照提交的顺序串行执行。这非常适合那些不需要并发,但又希望任务能在后台异步执行,并保证顺序性的场景。
- 实现:
总结一下:无论是哪种由 Executors 创建的线程池,它们的底层实现都是通过精心配置 ThreadPoolExecutor 的七个核心参数来实现各自不同的工作特性。在实际开发中,阿里巴巴的开发规范推荐我们不直接使用 Executors 的这些方法,而是根据业务场景,手动创建 ThreadPoolExecutor,这样可以更精确地控制资源,避免无界队列等可能带来的风险。
25.add一个任务怎么运作的?
26.如果全满了 怎么办呢?
第一步:检查核心线程池是否已满?
- 线程池会首先检查当前正在运行的线程数是否小于
corePoolSize(核心线程数)。 - 如果是,线程池会立即创建一个新的核心线程来执行这个任务,即使其他核心线程现在是空闲的。这个设计的目的是为了尽快达到核心工作能力。
- 如果否(即核心线程数已满或更多),则进入第二步。
第二步:检查工作队列是否已满?
- 线程池会尝试将这个新任务添加到
workQueue(工作队列)中。 - 如果添加成功(通常意味着队列还没满),任务就会在队列中排队,等待某个空闲的线程来领取并执行它。任务提交过程到此结束。
- 如果添加失败(通常是因为队列已满,比如对于有界队列
ArrayBlockingQueue),则进入第三步。
第三步:检查最大线程池是否已满?
- 线程池会检查当前总的线程数是否小于
maximumPoolSize(最大线程数)。 - 如果是,线程池会创建一个新的非核心线程(我们之前比喻的“临时工”)来立即执行这个任务。
- 如果否(即总线程数已经达到了最大上限),那么线程池就无能为力了,它会进入最后的“拒绝”阶段。
如果全部都满了怎么办?(问题26)
当线程池经历上述三步决策后,发现核心线程满了、工作队列满了、总线程数也达到最大值了,就意味着线程池已经彻底饱和,无法再接收新的任务。
这时,线程池就会启动它的拒绝策略 (Rejection Policy),这个策略是在创建 ThreadPoolExecutor 时通过 rejectedExecutionHandler 参数指定的。
具体怎么办,就取决于我们选择了哪种拒绝策略:
-
AbortPolicy(中止策略):这是默认的策略。它会直接抛出一个RejectedExecutionException运行时异常。这是一种非常“激进”的策略,它会中断调用者的工作流程,如果这个异常没有被捕获,程序就会崩溃。 -
CallerRunsPolicy(调用者运行策略):这是一种非常巧妙的策略。它既不抛弃任务,也不抛出异常,而是将这个任务交由提交它的那个线程来亲自执行。- 效果:比如,如果主线程向线程池提交任务时被拒绝了,那么这个任务就会在主线程上同步执行。这会暂时阻塞主线程,从而减慢任务提交的速度,给线程池一个喘息和处理积压任务的机会。这是一种天然的“反压”或“降级”机制。
-
DiscardPolicy(丢弃策略):这是最“佛系”的策略。它会悄无声息地把这个任务直接丢掉,不做任何处理,也不会有任何异常。如果我们的任务不那么重要,丢失一两个也无所谓,那么可以使用这个策略。 -
DiscardOldestPolicy(丢弃最老任务策略):这个策略会去工作队列的头部,把排队时间最长的那个任务丢掉,然后再尝试把当前这个新任务重新加入队列。它试图通过牺牲一个老任务来为新任务腾出空间。
总结一下:当线程池全满时,它并不会立即崩溃,而是会根据我们预先设定的“应急预案”(拒绝策略)来决定是抛出异常、让提交者自己干、直接扔掉,还是扔掉一个老的再试试。在实际开发中,选择哪种策略,完全取决于业务场景对任务重要性的定义。
27.volite关键字的作用
volatile 关键字是 Java 提供的一种轻量级的同步机制。与重量级的 synchronized 锁相比,它更简单,开销也更小,但功能也更受限。它的核心作用可以概括为以下两点:
1. 保证可见性 (Visibility)
- 含义:这是
volatile最主要的作用。它能保证一个线程对volatile变量的修改,能够立即被其他线程看到。 - 实现原理:在多核 CPU 架构下,每个线程都有自己的工作内存(通常是 CPU 的高速缓存)。当一个线程修改一个共享变量时,它首先是在自己的工作内存中修改,并不会立即写回主内存。这就导致其他线程可能读取到的是旧的、过期的值。
- 当一个变量被声明为
volatile后,Java 内存模型 (JMM) 会确保:- 写操作:对这个变量的写操作会立即被刷新到主内存中。
- 读操作:在读取这个变量时,会强制从主内存中重新加载,使当前线程的工作内存中的缓存失效。
- 通过这种方式,
volatile就像在多个线程和主内存之间建立了一条“绿色通道”,保证了数据的实时可见性。
- 当一个变量被声明为
2. 禁止指令重排序 (Ordering)
- 含义:为了提高性能,编译器和处理器可能会对我们写的代码指令进行重排序。在单线程环境下,这通常没问题,因为结果是一致的。但在多线程环境下,重排序可能会导致意想不到的逻辑错误。
- 实现原理:
volatile关键字通过插入内存屏障 (Memory Barrier) 来阻止指令重排序。- 在对
volatile变量进行写操作时,它会确保这个写操作之前的所有普通操作都已经执行完毕,并且结果对其他线程可见。 - 在对
volatile变量进行读操作时,它会确保这个读操作之后的所有普通操作,必须在读完之后才能执行。 - 一个经典的例子就是双重检查锁定 (Double-Checked Locking) 的单例模式,其中的
instance变量就必须用volatile修饰,以防止因指令重排序导致其他线程获取到一个尚未完全构造好的对象。
- 在对
volatile 的局限性:不保证原子性
这一点非常重要,也是面试中经常考察的。volatile 不能保证复合操作的原子性。
- 典型例子:
count++操作。这个操作看起来是一行代码,但实际上它包含了三个步骤:- 读取
count的值。 - 将值加 1。
- 将新值写回。
- 读取
- 即使
count是volatile的,也只能保证每次的“读”和“写”都是从主内存进行的。但如果两个线程同时执行count++,它们可能同时读取到相同的值(比如 5),然后各自加 1 得到 6,再先后写回。最终结果是 6,而不是期望的 7,这就发生了数据覆盖。 - 对于需要保证原子性的复合操作,我们仍然需要使用
synchronized关键字或者java.util.concurrent.atomic包下的原子类,比如AtomicInteger。
总结
volatile 是一个用于实现线程间状态通信的轻量级工具。它的核心价值在于以较低的成本保证了共享变量的可见性和一定程度的有序性。它最适合的场景是**“一个线程写,多个线程读”**的状态标志位,比如我在上一个问题中设计的线程池里的 isShutdown 标志,就非常适合用 volatile 来修饰。但我们必须清楚,它不能替代 synchronized 来保证复合操作的原子性。
28.让你自己设计一个线程池 你怎么设计 介绍一下 然后再简单写一下代码
让我自己设计一个线程池,我会把它看作一个典型的“生产者-消费者”模型,并围绕几个核心组件来构建。我的设计目标是实现一个基础但健壮的线程池,包含任务提交、线程管理和关闭等核心功能。
我的设计会包含以下几个关键部分:
1. 核心组件
- 任务队列 (Task Queue):一个线程安全的阻塞队列,用于存放待执行的任务。我会选用
BlockingQueue<Runnable>,因为它天然解决了生产者(提交任务的线程)和消费者(工作线程)之间的同步问题。当队列为空时,工作线程调用take()会自动阻塞,避免了CPU空转。 - 工作线程 (Worker Thread):这是真正执行任务的线程。我会设计一个
Worker类,它本身是一个Runnable。它的核心逻辑是一个循环:不断地从任务队列中取出任务并执行。这个循环需要一个可控的退出机制。 - 线程池管理器 (ThreadPool Manager):这是线程池的主类,负责管理工作线程、接收外部提交的任务,并控制整个线程池的生命周期(如启动和关闭)。
2. 核心流程设计
-
初始化流程:
- 在创建线程池时,我会传入一个核心线程数
coreSize。 - 构造函数会初始化一个
BlockingQueue和一个用于存放工作线程的集合,比如List<Worker>。 - 然后,我会根据
coreSize,创建相应数量的Worker实例,启动它们,并把它们加入到工作线程集合中。这些Worker一启动就会阻塞在任务队列的take()方法上,等待任务的到来。
- 在创建线程池时,我会传入一个核心线程数
-
任务提交流程 (
execute(Runnable task)):- 当外部调用
execute方法提交一个任务时,管理器要做的非常简单:就是把这个task放入任务队列中 (queue.put(task))。 - 一旦任务入队,某个正在阻塞等待的
Worker线程就会被唤醒,拿到这个任务,然后执行它的run()方法。 - 在提交前,需要检查线程池是否已经关闭。如果已关闭,就应该执行一个拒绝策略,比如直接抛出异常。
- 当外部调用
-
线程池关闭流程 (
shutdown()):- 关闭是一个关键且复杂的操作。一个优雅的关闭需要确保已提交的任务都被执行完毕,但不再接受新任务。
- 我会设计一个
shutdown()方法。当它被调用时:- 首先,设置一个
volatile的状态标志位,比如isShutdown = true。这会阻止新任务被提交到队列。 - 然后,需要唤醒所有可能因为队列为空而阻塞的
Worker线程。因为Worker的循环条件是while (!isShutdown),如果它们一直阻塞在take(),就永远无法检查这个标志位。所以,我会遍历所有工作线程,并调用它们的interrupt()方法。 Worker线程的run()方法中,queue.take()在被中断时会抛出InterruptedException。我会在catch块里捕获这个异常,然后再次检查isShutdown标志,从而优雅地退出循环,结束线程。
- 首先,设置一个
3. 简化代码实现
下面是一个非常简化的核心代码,用于展示我的设计思路:
4. 可扩展性
当然,这是一个非常基础的版本。如果要让它更完善,还可以从以下几个方面扩展:
- 实现拒绝策略:当队列有界且满了的时候,
execute方法应该如何处理。 - 实现
maximumPoolSize:在队列满了之后,可以动态创建临时线程,直到达到最大线程数。 - 实现
keepAliveTime:需要一个额外的监控线程来回收空闲超时的临时线程。 - 实现
submit方法:可以返回一个Future对象,用于获取任务执行结果或取消任务。
但上述的设计和代码已经完整地勾勒出了一个线程池最核心的骨架。
40.怎么设计一个 相册呢?九宫格样式的
设计一个九宫格样式的相册,我会毫不犹豫地选择使用 RecyclerView。在现代 Android 开发中,RecyclerView 是构建任何形式列表或网格视图的首选和标准。如果项目是基于 Jetpack Compose 的,我则会使用 LazyVerticalGrid。
下面我将从 UI 实现、数据加载、性能优化 和 交互设计 四个方面,详细阐述我的设计思路。
1. UI 实现:选择 RecyclerView 并配置 GridLayoutManager
为什么是 RecyclerView 而不是老的 GridView?
- 性能:
RecyclerView强制使用ViewHolder模式,能高效地复用 Item View,极大地减少了findViewById的开销和内存抖动,性能远超GridView。 - 灵活性:通过更换
LayoutManager,我可以轻松地将九宫格布局切换成线性列表、瀑布流等任何样式,而无需改动一行 Adapter 的代码。 - 功能强大:它内置了对 Item 动画的强大支持,并且易于扩展和定制。
实现步骤:
- 在 XML 布局文件中放置一个
RecyclerView。 - 在 Activity 或 Fragment 中,为
RecyclerView设置一个GridLayoutManager。我会这样初始化它:recyclerView.layoutManager = GridLayoutManager(context, 3),这里的3就代表了九宫格的列数。 - 为了让格子之间有间距,我会自定义一个
RecyclerView.ItemDecoration,通过重写getItemOffsets方法,为每个 Item 设置均匀的left,top,right,bottom间距。
2. 数据加载:异步获取本地图片
相册的数据源是手机里的图片,这是一个耗时操作,绝对不能在主线程进行。
-
权限申请:首先,必须在
AndroidManifest.xml中声明权限。对于 Android 13 及以上,是<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />;对于低版本,是READ_EXTERNAL_STORAGE。在代码中,我会使用现代的ActivityResultLauncher来动态请求这些权限。 -
异步查询:获取权限后,我会启动一个协程 (Coroutine) 或者使用其他异步方案,在后台线程通过
ContentResolver查询MediaStoreAPI。具体来说,是查询MediaStore.Images.Media.EXTERNAL_CONTENT_URI,获取每张图片的路径 (DATA)、ID、修改日期等信息。 -
数据回传:查询完成后,将获取到的图片路径列表通过
LiveData或StateFlow回传给 UI 层。UI 层观察到数据变化后,调用Adapter的submitList或setData方法来更新界面。
3. 性能优化:高效的图片加载与显示
这是相册设计的核心和难点。直接加载大量高清原图会迅速导致内存溢出 (OOM)。
-
选择图片加载库:我会使用成熟的第三方图片加载库,比如 Glide 或 Coil (对于 Kotlin 项目)。这些库的优势是:
- 自动缓存:内置了内存缓存和磁盘缓存,避免重复加载。
- 生命周期感知:能自动管理加载请求的生命周期,避免内存泄漏。
- 图片采样:能根据
ImageView的大小,自动加载一个合适尺寸的缩略图到内存中,而不是加载原图,这是避免 OOM 的关键。 - 占位图与过渡:支持设置加载中和加载失败的占位图,并提供平滑的过渡动画。
-
Adapter 中的实践:在
RecyclerView.Adapter的onBindViewHolder方法中,我不会手动加载图片,而是将这个工作完全委托给 Glide 或 Coil。代码会非常简洁,类似这样:Glide.with(context).load(imagePath).centerCrop().into(imageView)。centerCrop()可以保证图片不变形地填满整个九宫格。
4. 交互设计
- 点击事件:在
Adapter的ViewHolder中为itemView设置点击监听器。当用户点击一个九宫格时,获取该图片的信息(如路径或 URI)。 - 查看大图:点击后,通常会跳转到一个新的 Activity 或 Fragment,用于展示单张可滑动的大图。这个大图页面我会用
ViewPager2来实现,并将图片路径列表和当前点击的位置传递过去,从而实现左右滑动切换图片的功能。
总结一下我的设计思路:以 RecyclerView + GridLayoutManager 为骨架,通过 ContentResolver 在后台异步加载图片数据,然后利用 Glide 或 Coil 这样的专业库来高效、安全地处理图片的加载和显示,最后通过 ViewPager2 实现大图预览,从而构建一个完整、高性能且体验良好的九宫格相册。
2.有三行代码。loga handler(logb) logc.都在主线程里面。执行顺序是什么
3.如果耗时 执行顺序是什么
log a -> log c -> log b
下面我来解释一下为什么是这个顺序,这背后涉及到 Android 的主线程消息循环机制,即 Looper、Handler 和 MessageQueue 的协同工作。
-
执行
log a:log a是一个普通的同步方法调用。当主线程执行到这一行时,它会立即、同步地执行这个打印操作。所以,“a”会第一个被打印出来。
-
执行
handler(log b):- 这一行代码,我理解为
handler.post(() -> log b)或者类似的操作。这里的关键在于handler.post()这个动作本身。 - 它并不是立即执行
log b。相反,它做的是:- 创建一个
Message对象,并将包含log b的Runnable作为callback放入这个Message中。 - 将这个
Message对象放入主线程的MessageQueue(消息队列)的队尾进行排队。
- 创建一个
- 这个
post方法本身执行得非常快,它只是一个“入队”操作,执行完后会立即返回,主线程会继续往下执行。它并不会等待log b真正被执行。
- 这一行代码,我理解为
-
执行
log c:- 由于
handler.post()立即返回了,主线程的执行流程没有被阻塞,所以它会紧接着执行下一行代码,也就是log c。 - 和
log a一样,这也是一个同步方法调用,所以“c”会紧随“a”之后被打印出来。
- 由于
-
最后执行
log b:- 当主线程完成了当前这段同步代码块(即执行完
log a和log c)之后,它会回到自己的Looper.loop()无限循环中。 - 在这个循环里,
Looper会去检查MessageQueue中是否有新的消息。这时,它会发现我们刚刚通过handler放入的那个包含log b的消息。 Looper会从队列中取出这个消息,并执行它的callback,也就是log b这个Runnable。- 因此,“b”是在
log a和log c执行完毕,主线程空闲下来之后,才从消息队列中被取出来执行的。
- 当主线程完成了当前这段同步代码块(即执行完
总结一下:同步代码 (log a, log c) 在当前执行流中拥有最高优先级,会立即执行。而通过 Handler 发送的消息 (log b),会被放入队列中,等待当前同步代码执行完毕、主线程进入下一次消息循环时才被处理。
4.如果不耗时 执行顺序是什么
即使这三个操作本身都不耗时,执行顺序依然是 log a -> log c -> log b。
这个执行顺序是由 Android 的主线程消息循环机制决定的,与任务本身是否耗时无关。
我来再次解释一下这个关键点:
-
log a是同步代码:当主线程的执行指针指向它时,它就被立即执行。无论它耗时 1 小时还是 1 纳秒,它都是当前流程中的第一步。 -
handler(log b)是异步消息发送:这一行代码的本质不是“执行 b”,而是“安排 b 在未来执行”。handler.post()这个方法本身几乎不耗时,它的工作就是把一个任务打包成Message,然后把它扔到MessageQueue的队尾。- 这个动作完成得非常快,并且它会立即返回,主线程会继续执行下一行代码。它并不会等待
log b被执行。
-
log c是同步代码:由于handler.post()已经迅速执行并返回,主线程的控制权马上就移到了log c这一行。因此,它会紧接着log a之后被立即执行。
核心在于理解“未来”是多久?
在 Handler 的世界里,这个“未来”指的是主线程 Looper 的下一个循环周期。
主线程一直在一个 while(true) 的循环(即 Looper.loop())里,它的工作流程是:
- 执行一段同步代码块(比如我们这个场景里的
log a和log c)。 - 执行完毕后,回到循环的起点。
- 检查
MessageQueue里有没有待处理的消息。 - 发现有
log b这个消息,于是取出来执行它。 - 处理完后,继续回到循环起点,等待下一段同步代码或下一个消息。
所以,无论 log a、b、c 本身有多快,log b 都必须排队,必须等到 log a 和 log c 这段同步代码执行完毕后,主线程空闲下来,重新开始它的消息循环时,才有机会被执行。
总结一下:这个问题的关键在于区分同步执行和异步调度。log a 和 log c 是同步执行,而 handler(log b) 是一个异步调度操作。调度本身是同步且快速的,但被调度的任务的执行是延迟的。这个延迟与任务耗时无关,而是由消息队列的排队机制决定的。
5.java 的常用引用类型
Java 中除了我们最常用的普通对象引用(即强引用)之外,为了让开发者能更灵活地控制对象的生命周期和内存管理,java.lang.ref 包下还提供了另外三种引用类型。所以,Java 中总共有四种引用类型,按引用强度从高到低排列分别是:
1. 强引用 (Strong Reference)
- 特点:这是我们日常编程中最常见的引用类型,比如
Object obj = new Object();。只要一个对象有强引用指向它,那么垃圾回收器(GC)就绝对不会回收这个对象,即使系统内存已经非常紧张,宁可抛出OutOfMemoryError异常也不会回收。 - 生命周期:它的生命周期是最长的,由开发者通过代码来控制。当不再需要这个对象时,我们需要手动将引用设置为
null(比如obj = null;),这样 GC 在下次运行时才能发现并回收它。在 Android 中,忘记释放强引用是导致内存泄漏最常见的原因。
2. 软引用 (Soft Reference)
- 特点:软引用关联的对象是一种“内存敏感”的存在。当系统内存充足时,GC 不会回收它;但当系统内存即将不足,快要发生 OOM 之前,GC 就会把这些只被软引用的对象给回收掉。
- 用途:它非常适合用来实现内存敏感的高速缓存。比如,一个图片缓存系统。我们可以用软引用来持有 Bitmap 对象,内存充足时,图片可以快速从缓存中获取;内存紧张时,系统会自动回收这些 Bitmap 来释放内存,避免程序崩溃,下次需要时重新从磁盘或网络加载即可。
3. 弱引用 (Weak Reference)
- 特点:弱引用的强度比软引用更弱。一个对象如果只被弱引用指向,那么它只能存活到下一次垃圾回收发生之前。无论当前内存是否充足,只要 GC 开始工作,这个对象就会被回收。
- 用途:弱引用在 Android 开发中非常非常常用,主要用来防止内存泄漏。
- 最经典的例子就是在自定义 Handler 中,如果 Handler 是一个非静态内部类,它会持有外部 Activity 的强引用。如果 Handler 发送了一个延迟消息,在消息处理前 Activity 退出了,那么这个 Activity 的实例会因为被 Handler 强引用而无法被回收,造成内存泄漏。解决方案就是让 Handler 持有 Activity 的一个弱引用,这样 Activity 就可以被正常回收。
- 另一个例子是
WeakHashMap,它的 Key 就是弱引用,一旦 Key 对象没有其他强引用了,这个键值对就会被自动从 Map 中移除。
4. 虚引用 (Phantom Reference)
- 特点:这是最弱的一种引用,也叫“幽灵引用”或“幻影引用”。它完全不影响对象的生命周期。一个对象是否有虚引用,对于 GC 来说,跟没有引用一样,随时都可能被回收。
- 用途:它的唯一作用,就是在对象被 GC 回收时,能够收到一个系统通知。我们必须将虚引用和一个引用队列 (ReferenceQueue) 联合使用。当 GC 准备回收一个对象时,如果发现它有虚引用,就会在回收对象之后,把这个虚引用加入到与之关联的引用队列中。程序可以通过检查这个队列,得知某个对象已经被回收了。
- 应用场景:它主要用于管理堆外内存(Direct Memory),比如
DirectByteBuffer。当DirectByteBuffer对象被回收时,可以通过虚引用机制得到通知,从而调用相应的free()方法来释放它占用的堆外内存。在日常应用层开发中,我们很少会直接使用它。
总结一下:这四种引用类型为我们提供了从“宁死不屈”(强引用)到“死后通知”(虚引用)的一整套与 GC 交互的工具,让我们能够根据业务场景,在防止内存泄漏和利用内存缓存之间做出精细的平衡。
12.假如你是java设计者,你会怎么改进设计gc
我的设计哲学核心是:从“被动的垃圾回收”转向“主动的内存管理协作”。
具体来说,我会从以下三个方面着手改进:
1. 引入“分代区域化”与“动态目标”的 GC 算法 (The Algorithm)
- 融合 G1 和 ZGC 的优点:我会设计一个基于“区域(Region)”的垃圾回收器,这一点类似 G1 和 ZGC。但我会更强调“分代”的概念。新生代对象分配在专门的小区域,老年代对象在另外的区域。
- 核心改进:动态调整目标:当前的 GC 调优,比如设置停顿时间目标,还是相对静态的。我会让 GC 变得更“有眼色”。它会感知应用的当前上下文。
- 比如,在 Android 应用中,当 GC 感知到用户正在进行滑动操作时(可以通过与 FrameWork 层联动),它会自动将目标调整为**“极低延迟模式”**,宁可牺牲一些吞吐量,也要保证不掉帧。
- 当应用处于后台或空闲状态时,GC 会自动切换到**“高吞吐量模式”**,进行一次更彻底、更深度的整理,比如进行更激进的内存压缩,以减少整体内存占用。
2. 建立“开发者-GC”协作通道 (The Collaboration)
目前 GC 对开发者来说几乎是一个黑盒。我认为可以建立一个桥梁,让开发者能够给 GC 提供一些“提示 (Hints)”,而不是直接干预。
-
引入新的语言关键字或注解:
@Scoped或@Ephemeral注解:开发者可以用它来标记一个对象,明确告诉 GC 这个对象的作用域非常有限,比如只在某个方法内使用,绝不会逃逸。GC 拿到这个提示后,可以对这个对象采用更激进、更廉价的回收策略,甚至可能在栈上分配,或者在一个独立的、清理成本极低的“临时区域”分配,从而完全避免进入传统的堆和分代体系。这有点像 Rust 的所有权概念的简化版思想。@LongLived注解:对于那些我们明确知道会存活很久的对象,比如单例、全局缓存,可以用这个注解标记。GC 可以直接将这些对象分配到老年代的特定区域,减少它们在新生代被反复扫描的开销。
-
提供更丰富的诊断 API:提供一个标准的 API,让开发者可以无副作用地查询“为什么这个对象还活着?”,得到一个清晰的 GC Root 引用链。这能极大地简化内存泄漏的排查,而不是过度依赖第三方的 Profiler 工具。
3. 探索基于硬件和 AI 的预测性 GC (The Future)
- 硬件辅助:与 CPU 制造商合作,在硬件层面加入一些对 GC 更友好的指令集。比如,硬件可以直接支持“写屏障(Write Barrier)”的某些操作,降低其在软件层面实现的开销。
- AI 预测模型:在 JVM 中内嵌一个轻量级的机器学习模型。这个模型会在运行时持续学习应用的对象分配模式 (Allocation Patterns)。
- 比如,模型可能会发现“每次网络请求后,都会创建大量的 JSON 解析相关的临时对象”。
- 基于这个预测,GC 可以在下一次网络请求开始前,就预先准备好一块专门用于回收的内存区域,或者临时调整新生代的大小,从而实现更高效的“预测性”垃圾回收,而不是总是“滞后反应”。
总结一下:我的设计思路不是推倒重来,而是让 GC 从一个埋头干活的“清洁工”,变成一个能与系统、开发者、甚至硬件和 AI 模型进行智能协作的“内存管家”。它更懂应用的状态,更理解开发者的意图,也更有预见性,最终目标是在各种复杂场景下,都能以最低的成本达成最优的内存管理效果。
14.事件分发机制 消费机制
15.事件没有消费的话,怎么传回去的?
Android 的事件分发和消费机制是 View 体系中非常核心的一环,它遵循一个清晰且定义明确的规则。我可以将它概括为一个 U 型的传递链路。
这个链路主要涉及三个关键方法:
dispatchTouchEvent(MotionEvent ev):事件分发。这是事件传递的入口,负责将事件派发下去。Activity, ViewGroup, View 都有这个方法。onInterceptTouchEvent(MotionEvent ev):事件拦截。这是 ViewGroup 特有的方法,它像一个“保安”,决定是自己处理事件,还是放行给子 View。onTouchEvent(MotionEvent ev):事件处理/消费。这是真正处理事件的地方。ViewGroup 和 View 都有这个方法。
事件分发机制 (U 型链路的“下沉”部分)
事件的传递是从上到下的,起点是 Activity:
-
Activity -> ViewGroup:当一个触摸事件(比如
ACTION_DOWN)发生时,首先由Activity的dispatchTouchEvent接收。它会把事件传递给它的根ViewGroup。 -
ViewGroup 的决策:当
ViewGroup的dispatchTouchEvent被调用时,它内部会做一个关键决策:- 它会先调用自己的
onInterceptTouchEvent方法,问一下自己:“我要不要拦截这个事件?” - 如果
onInterceptTouchEvent返回true(拦截):事件将不再向下传递给任何子 View。ViewGroup会认为“这个事件我来处理”,然后调用自己的onTouchEvent方法。 - 如果
onInterceptTouchEvent返回false(不拦截):ViewGroup就会遍历它的所有子 View,找到被触摸的那个,并调用那个子 View 的dispatchTouchEvent方法,把事件交给了下一层。
- 它会先调用自己的
-
ViewGroup -> View:这个过程会递归地在嵌套的
ViewGroup中进行。最终,事件会传递到最底层的、被用户直接触摸到的那个View的dispatchTouchEvent方法。
事件消费机制与回传机制 (U 型链路的“上浮”部分)
事件的消费和回传,是通过方法的返回值来实现的。true 代表“我消费了”,false 代表“我不处理,还给你”。
-
View的消费:- 当事件到达最底层的
View时,它的dispatchTouchEvent会调用自己的onTouchEvent。 - 消费:如果这个
View是可点击的(比如一个Button),或者它的onTouchListener返回了true,那么它的onTouchEvent通常会返回true。这表示:“事件我消费了,到此为止”。 - 不消费 (问题 15 的起点):如果
View的onTouchEvent返回了false,就表示:“我不想处理这个事件”。
- 当事件到达最底层的
-
事件如何“传回去” (问题 15):
- “传回去”这个动作,其实就是方法调用的
return过程。 - 如果子
View的onTouchEvent返回false,那么子View的dispatchTouchEvent也会返回false。 - 这个
false返回值会传递给上一层的父ViewGroup。父ViewGroup在它的dispatchTouchEvent方法里,就收到了子 View “不消费”的信号。
- “传回去”这个动作,其实就是方法调用的
-
父
ViewGroup的二次机会:- 当父
ViewGroup发现子 View 没有消费事件时(即child.dispatchTouchEvent(ev)返回了false),它会觉得“既然小弟不干,那就我来干吧”。 - 这时,父
ViewGroup就会调用它自己的onTouchEvent来尝试处理这个事件。 - 如果父
ViewGroup的onTouchEvent返回了true,那么事件就被它消费了。如果它也返回false,那么事件会继续以同样的方式“冒泡”给它的上一层父ViewGroup。
- 当父
-
最终的终点:如果整个 View 层级从下到上,没有任何一个 View 或 ViewGroup 消费这个事件,它最终会回到
Activity的onTouchEvent方法。如果Activity也不处理,这个事件就被彻底抛弃了。
一个重要的补充规则:一旦某个 View 在 ACTION_DOWN 事件中返回了 true,那么后续的同一系列事件(如 ACTION_MOVE, ACTION_UP)将会绕过所有 onInterceptTouchEvent 的检查,直接被分发到这个消费了 ACTION_DOWN 的 View 的 onTouchEvent。系统认定了“就是你要处理这一整套动作”。
17.java泛型了解吗?
Java 泛型,或者说“参数化类型”,是 JDK 5 引入的一个重要特性。它的核心价值在于,允许我们在定义类、接口和方法时,使用一个“类型占位符”,等到使用时再指定具体的类型。
引入泛型主要带来了三大好处:
-
类型安全 (Type Safety):这是泛型最核心的优势。它将类型检查从运行时提前到了编译时。例如,在没有泛型的时代,我们创建一个
ArrayList,可以往里面放任何类型的对象。取出来的时候,我们必须强制类型转换,如果类型不匹配,就会在运行时抛出ClassCastException。而有了泛型,ArrayList<String>在编译阶段就杜绝了任何非String类型的对象被添加进去的可能,从根本上消除了这类运行时异常。 -
消除代码中的强制类型转换:由于编译时已经保证了类型安全,我们在从泛型集合中获取元素时,就不再需要进行手动强转,代码变得更简洁,可读性也更高。
-
代码复用性:我们可以编写一个泛型算法或数据结构,比如
List<T>,它可以服务于任何数据类型,而无需为每种类型都编写一套重复的代码。
18.泛型是真的产生了相应的类型吗?
不,Java 的泛型并没有在运行时真正产生新的类型。
这背后的机制,就是 Java 泛型的一个核心概念,叫做 类型擦除 (Type Erasure)。
具体来说,Java 的泛型只存在于编译阶段,用于进行类型检查和转换。一旦编译完成,生成的 .class 文件中,所有泛型相关的信息都会被“擦除”掉,并替换为它们的上界(Upper Bound)。
这个过程可以分为三步:
-
编译时检查:编译器会利用泛型信息,比如
List<String>,来确保所有操作都是类型安全的。比如,list.add(123)这样的代码会直接编译失败。 -
类型擦除:编译成功后,编译器会擦除代码中的泛型信息。
- 如果泛型没有指定边界,比如
<T>,则用Object替换。所以List<T>和List<String>在字节码层面都会变成List。 - 如果泛型指定了边界,比如
<T extends Number>,则用边界类型Number替换。
- 如果泛型没有指定边界,比如
-
插入桥接方法和强转:在需要的地方,编译器会自动插入强制类型转换的代码,来保证运行时的类型正确性。比如,我们写的
String s = list.get(0);,在编译后会变成String s = (String) list.get(0);。这个转换是我们看不到的,由编译器代劳了。
如何证明类型被擦除了呢?
最直接的证据就是,在运行时,ArrayList<String> 和 ArrayList<Integer> 的 Class 对象是完全相同的。
ArrayList<String> stringList = new ArrayList<>();
ArrayList<Integer> integerList = new ArrayList<>();
// 这行代码会返回 true
System.out.println(stringList.getClass() == integerList.getClass());
因为在运行时,它们都被擦除成了 ArrayList 这个原始类型。
总结一下:Java 的泛型是一种“伪泛型”或“编译时泛型”。它通过在编译期做严格的检查,然后通过类型擦除和编译器自动添加转换代码的方式,巧妙地在不改动 JVM 的情况下,实现了泛型编程的诸多好处。这种设计的权衡,主要是为了兼容 JDK 5 之前的代码。
21.你了解service吗?
22.自己构造一个service有问题吗?
Service 是 Android 四大组件之一,它是一个专门用于在后台执行长时间运行操作而没有用户界面的组件。比如播放音乐、下载文件、处理网络事务等。
自己构造一个 Service 不仅没有问题,而且是 Android 开发中的一个常规操作。我们通过继承 Service 类或其子类(如已废弃的 IntentService)来创建自己的 Service,并在 AndroidManifest.xml 中进行注册。
根据启动方式和用途,我们自己写的 Service 主要分为两种:
- 启动状态 (Started Service):通过
startService()启动。这种 Service 一旦启动,就会在后台无限期运行,直到它自己调用stopSelf()或其他组件调用stopService()。它与启动它的组件没有直接的绑定关系。 - 绑定状态 (Bound Service):通过
bindService()启动。这种 Service 提供了客户端-服务器接口,允许组件(客户端)与 Service 进行交互、发送请求、获取结果,甚至进行跨进程通信 (IPC)。只要有客户端绑定到它,它就会一直运行。
在现代 Android 开发中,对于需要保证执行的任务,我们还会使用前台服务 (Foreground Service),它会显示一个用户可见的通知,优先级非常高,系统几乎不会杀死它。而对于可延迟的后台任务,Google 推荐使用 WorkManager,它能更好地管理系统资源和电量。
23.自己的service和系统的service的劣势是什么
24.不同的点有哪些?
我们自己写的 Service(我称之为“应用 Service”)和 Android 系统提供的 Service(“系统 Service”)有本质的区别,这些不同点也决定了我们应用 Service 的“劣势”。
我从以下几个维度来对比它们的不同点,其中也包含了应用 Service 的劣势:
1. 进程与权限 (最大的不同)
- 应用 Service:运行在我们自己应用的进程中。它的权限受限于我们在
AndroidManifest中申请的权限,并且受到 Android 沙箱机制的严格限制。 - 系统 Service:运行在一个独立的、高权限的系统进程中(通常是
system_server进程)。它们拥有系统级的权限,可以执行非常底层的操作,比如管理所有应用窗口 (WindowManagerService)、管理网络连接 (ConnectivityService) 等,这些是应用 Service 绝对做不到的。 - 劣势体现:我们的 Service 权限低,能做的事情非常有限,完全被框定在应用层面。
2. 生命周期与稳定性
- 应用 Service:生命周期由我们自己和 Android 系统共同管理。当系统内存不足时,我们的后台 Service 很有可能被系统回收或杀死。它的稳定性相对较低。
- 系统 Service:由系统在启动时创建,并且会一直稳定运行,直到设备关机。它们是 Android OS 的核心组成部分,稳定性极高,几乎不可能被杀死。
- 劣势体现:我们的 Service 是“不可靠”的,随时可能因为系统资源问题而中断。
3. 访问方式
- 应用 Service:通过我们自己定义的
Intent来启动或绑定。 - 系统 Service:我们不能直接访问或创建系统 Service 的实例。Android 框架为我们提供了一套
Manager类的封装,比如LocationManager,ActivityManager等。我们通过context.getSystemService(Context.LOCATION_SERVICE)获取到这个Manager对象,Manager内部通过 Binder IPC 机制 与真正的系统 Service 进行跨进程通信。 - 劣势体现:这不是劣势,而是设计上的不同。系统 Service 通过统一的
ManagerAPI 提供了稳定、安全的接口,隐藏了底层的 IPC 细节。
4. 作用与角色
- 应用 Service:其作用是为单个应用提供特定的后台功能。比如音乐 App 的播放服务只为这个 App 服务。
- 系统 Service:其作用是为整个系统和所有应用提供基础的、公共的核心功能。它是一个公共基础设施。
总结一下:我们自己写的 Service 是一个应用内的、权限受限的、生命周期不稳定的后台工作单元。而系统 Service 是 OS 级别的、高权限的、永不宕机的核心基础设施。我们的 Service 的“劣势”正是由它作为“上层建筑”的角色所决定的,它必须服从于系统对资源和权限的统一管理。
25.你有一个下载需求,我是迅雷,你给我一个url,我去下载。这种情况下,我内部的service要怎么设计呢?启动方式怎么样?
26.你想让别人调用你的service,你怎么设计?
我的设计核心是:采用“混合启动”模式,并提供一个基于 Binder 的清晰接口供外部调用。
1. 启动与交互方式 (startService + bindService)
- 混合启动:我会同时使用
startService()和bindService()。startService(intent):当用户首次添加一个下载任务时,UI 层会调用startService()。这样做的目的是确保 Service 的独立生命周期。即使我的 UI (Activity) 退出了、解绑了,下载任务依然可以在后台继续进行,不会因为没有组件绑定而销毁。bindService(intent, ...):UI 启动后,会立刻调用bindService()来建立一个通信连接。通过这个连接,UI 可以调用 Service 的方法(如下载、暂停),并且可以接收 Service 回传的实时进度。当 UI 退出时,调用unbindService()断开连接即可。
2. 内部架构设计
- 前台服务 (Foreground Service):下载是长时间的后台任务,为了防止被系统在内存不足时杀死,我必须在第一个下载任务开始时,调用
startForeground(),将 Service 提升为前台服务,并展示一个包含下载进度的、不可清除的通知。这是现代 Android 系统对后台任务的强制要求。 - 多线程管理:Service 本身运行在主线程,所有耗时的下载操作必须在子线程执行。我会内置一个线程池 (
ExecutorService) 来管理并发的下载任务,比如允许最多 3 个任务同时下载。 - 任务管理与持久化:
- 在 Service 内部,我会用一个
ConcurrentHashMap来管理所有下载任务的状态,Key 可以是 URL 或任务 ID。 - 为了防止应用被杀死后任务丢失,我会使用一个**数据库(比如 Room)**来做持久化,保存每个任务的 URL、本地路径、已下载字节数、总字节数、当前状态(等待、下载中、暂停、完成、失败)等信息。Service 启动时会从数据库恢复任务列表。
- 在 Service 内部,我会用一个
3. API 设计 (回答问题 26:如何让别人调用)
我会通过 Binder IPC 机制 暴露 Service 的能力。
- 定义接口:在 Service 内部,我会创建一个
DownloadBinder类,它继承自Binder。这个 Binder 会持有一个 Service 的实例。 onBind()返回实例:Service 的onBind()方法会返回这个DownloadBinder的实例。- 提供公共方法:在
DownloadBinder中,我会提供一系列公共方法作为 API,供客户端(比如 Activity)调用,例如:void startDownload(String url)void pauseDownload(String taskId)void resumeDownload(String taskId)DownloadInfo getDownloadInfo(String taskId)
- 回调机制:为了让 UI 实时更新进度,轮询
getDownloadInfo效率太低。我会设计一个回调机制。- API 中会增加
void registerCallback(IDownloadCallback callback)和void unregisterCallback(IDownloadCallback callback)。 IDownloadCallback是一个接口,里面有onProgress(String taskId, long downloaded, long total)、onStateChanged(String taskId, int newState)等方法。- Activity 实现这个回调接口,并在绑定成功后注册自己。Service 在下载进度更新时,就会遍历所有注册的回调,通知 UI 更新。
- API 中会增加
28.service的基础能力有哪些?
1. 后台执行能力 (Background Execution)
- 这是 Service 最根本、最核心的能力。它提供了一个标准的范式,让我们可以在没有用户界面的情况下,于后台执行长时间运行的操作。
- 无论是播放音乐、在后台下载文件,还是实时同步数据,这些都需要一个独立于 UI 的执行环境,而 Service 正是为此而生。
2. 独立的生命周期管理能力 (Independent Lifecycle Management)
- Service 并不是一个简单的后台线程。它拥有自己的一套、独立于 Activity 的生命周期回调方法,比如
onCreate(),onStartCommand(),onBind(), 和onDestroy()。 - 这套生命周期使得我们可以精确地管理后台任务的状态。我们知道应该在
onCreate()中进行一次性的初始化,在onStartCommand()中接收并处理任务,在onDestroy()中释放资源。这种可控性是普通线程所不具备的。
3. 组件间通信能力 (Inter-Component Communication)
- Service 不仅能默默干活,它还能作为“服务端”,与其他组件进行交互。这种能力主要通过
bindService()机制实现。 - 当其他组件(如 Activity)绑定到 Service 时,Service 可以通过
onBind()方法返回一个IBinder接口。这个接口就是 Service 对外暴露的 API,客户端可以通过它调用 Service 的方法、传递数据、甚至实现回调。 - 这种能力是实现复杂应用架构的基础,比如跨进程通信 (IPC) 就是建立在 Service 的 Binder 机制之上的。
4. 系统交互与优先级管理能力 (System Interaction & Priority Management)
- Service 并不是孤立运行的,它能与 Android 系统进行“沟通”,以争取更好的生存机会。
- 最典型的能力就是将自己提升为前台服务 (Foreground Service)。通过调用
startForeground(),Service 可以向系统申请更高的运行优先级,并显示一个用户可见的通知。这使得它在系统内存紧张时,被杀死的概率大大降低,从而保证了关键后台任务的持续运行。这是现代 Android 开发中执行后台任务的必要能力。
总结一下:Service 的基础能力,就是提供了一个拥有独立生命周期、可与组件和系统进行通信的、用于执行后台任务的标准组件。
29.tcp的三次握手
三次握手的目标是同步双方的初始序列号 (ISN) 并确认双方的收发能力都正常,从而建立一个可靠的连接。这个过程可以描述为:
-
第一次握手 (客户端 -> 服务器):
- 客户端想要建立连接,它会向服务器发送一个特殊的报文段。在这个报文中,
SYN标志位被置为 1,表示这是一个“请求同步”的报文。 - 同时,客户端会随机选择一个初始序列号
seq = x。 - 发送后,客户端进入
SYN_SENT状态,等待服务器的确认。
- 客户端想要建立连接,它会向服务器发送一个特殊的报文段。在这个报文中,
-
第二次握手 (服务器 -> 客户端):
- 服务器收到客户端的
SYN报文后,如果同意建立连接,它会回复一个确认报文。 - 在这个报文中,
SYN和ACK标志位都被置为 1。SYN=1表示服务器也要同步自己的序列号,ACK=1表示这是一个确认报文。 - 服务器同样会随机选择自己的初始序列号
seq = y。 - 同时,它会将确认号
ack设置为x + 1。这个ack=x+1的含义是:“我已成功收到了你序列号为x的报文,期待你下一个序列号为x+1的报文”。 - 发送后,服务器进入
SYN_RCVD状态。
- 服务器收到客户端的
-
第三次握手 (客户端 -> 服务器):
- 客户端收到服务器的
SYN+ACK报文后,会检查确认号ack是否为x+1。如果正确,客户端就知道服务器已经准备好了。 - 客户端会再发送一个确认报文给服务器。在这个报文中,
ACK标志位被置为 1。 - 它的序列号
seq会设置为x + 1,与服务器的期望一致。 - 它的确认号
ack会设置为y + 1,表示“我已成功收到了你序列号为y的报文,期待你下一个序列号为y+1的报文”。 - 这个报文发送后,客户端和服务器都进入
ESTABLISHED状态,连接建立成功,可以开始传输数据了。
- 客户端收到服务器的
31.tcp可靠是怎么保证的
三次握手只是建立了可靠的连接,而在数据传输过程中,TCP 通过一整套精密的机制来保证可靠性:
- 序列号与确认应答 (ACK):TCP 将数据拆分成最适合发送的数据块(报文段),并为每个报文段分配一个唯一的序列号。接收方收到数据后,会发送一个
ACK报文作为确认,告诉发送方“我收到了哪些数据”。 - 超时重传机制:发送方在发送数据后会启动一个计时器。如果在计时器超时之前没有收到对方的
ACK,它会认为数据丢失或损坏,并重新发送该数据。 - 校验和 (Checksum):在 TCP 报文的头部和数据部分,都有一个校验和字段。发送方计算并填充这个值,接收方在收到后会重新计算。如果校验和不匹配,说明数据在传输过程中发生了错误,接收方会丢弃这个报文(等待发送方超时重传)。
- 流量控制 (Flow Control):TCP 使用滑动窗口 (Sliding Window) 机制。接收方会通过
ACK报文告诉发送方自己还有多少缓冲区空间(窗口大小)。发送方根据这个窗口大小来动态调整自己的发送速率,防止因发送过快而导致接收方缓冲区溢出,从而避免数据丢失。 - 拥塞控制 (Congestion Control):TCP 不仅关心接收方的处理能力,还关心整个网络的状况。它通过慢启动、拥塞避免、快重传和快恢复等算法,来探测网络拥塞程度,并主动调整发送速率,防止因过度占用网络资源而导致整个网络瘫痪。
33.假如中间少了一次,会出现什么
这是一个非常关键的问题,它解释了为什么“三次”是建立可靠连接的最小值。
-
假如只有两次握手 (缺少第三次):
- 两次握手的问题在于,服务器无法确认客户端是否收到了自己的确认信息。
- 这会产生一个经典问题:考虑一个场景,客户端发送的第一个连接请求
SYN报文因为网络延迟,滞留了很久。客户端超时后,又重发了一个新的SYN报文,这次成功建立了连接,传输数据,然后断开。 - 一段时间后,那个早已失效的、旧的
SYN报文终于到达了服务器。服务器收到后,会误以为这是一个新的连接请求,于是向客户端发送SYN+ACK报文,并分配资源等待连接。 - 但在两次握手的模型下,服务器发出确认后就认为连接已建立。然而,客户端此时并不会理会这个过时的确认,因为它已经结束了上一次通信。
- 结果就是,服务器单方面地建立了一个“僵尸连接”,并白白地为这个连接分配和维持资源,造成了严重的资源浪费。
- 而有了第三次握手,服务器只有在收到客户端对自己的
SYN的最终确认后,才会认为连接建立成功。如果收不到,服务器就不会进入ESTABLISHED状态,资源最终会被释放。
-
假如缺少第二次握手,那更不可能了,因为服务器连自己的序列号都没法同步给客户端,双方无法进行有序的数据交换。
35.https有了解吗 假如tcp建立链接,假如我给你家人打电话,你妈接到了,你们不想内容泄露,https是怎么做到的 具体做了啥
36.你发的所有东西中间人全知道了,你要怎么防止呢?
37.证书是怎么保证可靠的呢?
38.仔细说下ca证书 公钥解密校验过程
39.怎么确认证书的真实性
HTTPS,本质上就是在 TCP 这条“电话线”接通之后,又额外建立起的一套安全层(SSL/TLS)。它通过一套精密的握手流程,同时解决了加密和身份验证这两个问题。这个流程也是对您所有问题的最终答案。
整个过程可以概括为以下几个关键步骤:
第一步:身份验证与密钥协商(The Handshake)
这是整个安全机制的核心,也是回答“如何防止中间人”和“证书如何保证可靠”的关键。
-
客户端发起请求 (Client Hello):我的手机(客户端)向服务器(我妈的手机)发起请求,会带上几样东西:
- 我支持的加密算法列表(比如 AES、RSA 等)。
- 一个随机数
client_random。
-
服务器响应并出示“身份证” (Server Hello & Certificate):
- 服务器收到后,会选择一套双方都支持的加密算法。
- 它也会生成一个随机数
server_random。 - 最关键的一步来了:服务器会发给我一张“数字证书 (Digital Certificate)”。这张证书就像是服务器的“带防伪标识的身份证”。
-
客户端验证“身份证”的真伪 (Certificate Verification) 这正是您问题的核心所在(问题 37, 38, 39)。我拿到这张“身份证”后,绝不会轻易相信它,而是会进行严格的验证:
-
a. 检查签发机构 (CA) 的可靠性:这张证书是由谁签发的?是不是由一个我操作系统(Android 系统)内置的、默认就信任的、权威的证书颁发机构 (CA)(比如 Let's Encrypt, DigiCert)签发的。我的手机系统里预装了一个“权威 CA 列表”,只有这个列表里的机构签发的证书,我才初步认可。
-
b. 验证证书的签名(防伪标识):这是最精妙的一步,也是对问题 38 “公钥解密校验过程”的回答。
- 权威的 CA 机构会用自己的 CA 私钥 对服务器证书的摘要(可以理解为证书的“指纹”)进行加密,这个加密后的结果就是“数字签名”。
- 我的手机会用系统里存着的、与之配对的 CA 公钥 去解密这个数字签名,得到一个解密后的“指纹 A”。
- 然后,我的手机会用同样的算法,计算一遍收到的服务器证书的“指纹 B”。
- 最后,比较指纹 A 和指纹 B 是否完全一致。如果一致,就证明:第一,这个证书确实是那个权威 CA 签发的(因为只有它的公钥能解开);第二,证书内容在传输过程中没有被中间人篡改过(因为指纹对得上)。
-
c. 检查证书的其他信息:我还会检查证书的有效期是否过期,以及证书上写的域名是否和我正在访问的域名一致。
-
-
生成“会话密钥”并安全交换:
- 一旦我确认了服务器的“身份证”是真的,我就会生成第三个随机数,我们称之为
pre-master secret。 - 为了防止中间人截获这个
pre-master secret(回答问题 36),我会用服务器证书里自带的那个服务器公钥对它进行加密。 - 因为只有服务器才有对应的服务器私钥,所以只有真正的服务器才能解开这个加密后的信息,中间人即使截获了也无法解密。
- 现在,客户端和服务器双方都拥有了三个相同的信息:
client_random、server_random和pre-master secret。它们会用一个相同的算法,将这三个数混合在一起,生成一个独一无二的、对称的“会话密钥 (Session Key)”。
- 一旦我确认了服务器的“身份证”是真的,我就会生成第三个随机数,我们称之为
第二步:对称加密通信
握手阶段结束后,双方都有了同一个、只有他俩知道的“会话密钥”。接下来的所有通信,包括我发送的 URL、HTTP 头部和返回的网页内容,都会用这个会话密钥进行对称加密。
对称加密的特点是速度非常快,非常适合加密大量的应用数据。因为会话密钥从未在网络上明文传输过,所以即使中间人能截获所有后续的加密数据包,他也因为没有密钥而无法解密。
总结一下:HTTPS 通过非对称加密(用公钥/私钥)来安全地协商出一个对称加密的密钥,并用数字证书和 CA 体系来保证对方身份的真实性。一旦安全的会话建立,后续就用高效的对称加密来保护所有通信内容。这个流程完美地解决了内容泄露、中间人攻击和身份伪造这三大问题。
42.hashmap是线程不安全的 说说你的理解
HashMap 的线程不安全,根源在于它的所有关键操作,比如 put 和 resize,都不是原子性的,并且没有为并发访问做任何保护。在多线程环境下,这会导致两种致命问题:
1. 数据丢失或覆盖 (Data Loss)
- 最简单的情况:当两个线程同时对同一个 key 进行
put操作时,因为操作不是原子的,一个线程的写入结果可能会被另一个线程无情地覆盖,导致数据丢失。 - 更复杂的情况:当两个线程同时向同一个空的桶(bucket)位置
put不同的 key 时,它们可能都会读取到该位置为null,然后各自创建新节点。但由于赋值操作的非原子性,最终只有一个节点会被成功放入,另一个节点就丢失了。
2. 导致死循环 (Infinite Loop) - 尤其在 JDK 7 中
- 这是最严重的问题。当多个线程同时
put数据,触发了HashMap内部的resize()(扩容)操作时,会发生灾难性的后果。 resize的过程是重新计算每个元素在新哈希表中的位置,并移动它们。在 JDK 7 中,这个移动过程采用的是“头插法”,在多线程并发操作下,链表节点的next指针可能会被错误地修改,从而形成一个环形链表。- 一旦环形链表形成,后续任何线程如果尝试
get这个桶里的数据,就会陷入无限循环,导致 CPU 占用率飙升到 100%,整个应用被拖垮。虽然 JDK 8 优化了扩容算法,并且在链表过长时会转为红黑树,这大大降低了死循环的风险,但并发下的数据覆盖等问题依然存在。
总结来说:HashMap 的设计目标是单线程下的极致性能,它完全没有考虑多线程下的数据一致性和结构完整性,因此在并发环境下使用它是绝对禁止的。
43.我现在就要并发 就不加锁 你怎么办
如果完全不让我们在 HashMap 对象外面套一个 synchronized 或者 ReentrantLock,那么我们必须使用专门为并发设计的 Map 实现。
我的首选方案是使用 JUC 包下的 ConcurrentHashMap。
ConcurrentHashMap 正是为了解决这个问题而生的,它通过一系列精巧的设计,实现了高并发和线程安全,同时又避免了对整个 Map 加一把大锁的低效方式。它的“无锁”或“低锁”特性体现在:
1. 锁分离/锁分段技术 (JDK 7)
- 在 JDK 7 中,
ConcurrentHashMap内部由一个Segment数组构成。每个Segment本身就像一个小的、线程安全的HashMap,并且拥有自己独立的锁。 - 当一个线程需要写入数据时,它只需要锁定目标数据所在的那个
Segment即可,而其他线程可以同时在不同的Segment中进行写入,互不干扰。 - 这种“锁分段”的设计,将一把大锁分解为多把小锁,极大地提高了并发写入的性能。
2. CAS + synchronized (JDK 8 及以后)
- JDK 8 对
ConcurrentHashMap做了革命性的重构,摒弃了Segment,采用了更细粒度的 CAS (Compare-And-Swap) 操作配合synchronized。 - 写入操作的逻辑是:
- 在向一个桶(bucket)中放入第一个节点时,它会使用 CAS 这种无锁的方式尝试写入。CAS 是一种乐观的原子操作,它会先比较内存值是否为预期值,如果是,才更新。这种无锁操作在无竞争或低竞争时效率极高。
- 如果 CAS 失败,说明遇到了并发竞争(有其他线程在操作这个桶),或者这个桶里已经有数据了。这时,它才会退化为使用
synchronized关键字,来锁定这个桶的头节点。 - 关键点:这里的锁粒度非常非常细,只锁住了要操作的那个桶的头节点,而不是像 JDK 7 那样锁住整个
Segment,更不是锁住整个 Map。因此,只要线程们操作的不是同一个桶,它们就完全可以并行执行。
get操作在大部分情况下是完全无锁的,因为它利用了volatile关键字保证的内存可见性,可以直接读取。
总结方案:面对“要并发,不加锁”的硬性要求,我不会手动去改造 HashMap,而是直接选用官方提供的、经过千锤百炼的 ConcurrentHashMap。它通过 CAS 和极细粒度的锁,实现了高性能的并发访问,完美地满足了您的需求。
44.描述一下消费者生产者的模型 (然后写一下代码)
1. 核心思想与组件
- 解耦:它将“生产任务”的线程(生产者)与“处理任务”的线程(消费者)分离开来。生产者不需要知道消费者是谁,也不需要关心任务是何时被消费的;反之亦然。
- 平衡:它通过一个共享的、有容量限制的**缓冲区(Buffer)或队列(Queue)**作为中介,来平衡生产者和消费者之间可能存在的速率差异。如果生产者速度快,消费者速度慢,缓冲区会逐渐填满,从而“阻塞”生产者,给消费者留出处理时间。反之,如果消费者快,缓冲区会变空,从而“阻塞”消费者,等待新任务。
这个模型主要由三部分构成:
- 生产者 (Producer):负责创建数据或任务,并将其放入共享缓冲区。
- 消费者 (Consumer):负责从共享缓冲区中取出数据或任务,并进行处理。
- 共享缓冲区 (Shared Buffer):一个线程安全的、有界的数据结构,用于存放生产者生产的数据,供消费者取用。
2. 关键的同步问题
要正确实现这个模型,必须解决两个关键的同步问题:
- 当缓冲区已满时,生产者必须停止生产并等待,直到消费者取走数据,腾出空间。
- 当缓冲区为空时,消费者必须停止消费并等待,直到生产者放入新的数据。
方案一:使用 BlockingQueue (推荐)
BlockingQueue 是一个线程安全的队列,它已经为我们完美地封装了生产者-消费者的所有阻塞逻辑。
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class ProducerConsumerWithBlockingQueue {
public static void main(String[] args) {
// 创建一个容量为10的共享阻塞队列
BlockingQueue<Integer> sharedQueue = new ArrayBlockingQueue<>(10);
// 创建并启动生产者线程
Thread producerThread = new Thread(new Producer(sharedQueue));
producerThread.start();
// 创建并启动消费者线程
Thread consumerThread = new Thread(new Consumer(sharedQueue));
consumerThread.start();
}
// 生产者
static class Producer implements Runnable {
private final BlockingQueue<Integer> queue;
public Producer(BlockingQueue<Integer> queue) {
this.queue = queue;
}
@Override
public void run() {
try {
for (int i = 0; i < 100; i++) {
System.out.println("Producing: " + i);
// put方法会在队列满时自动阻塞
queue.put(i);
Thread.sleep(50); // 模拟生产耗时
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// 消费者
static class Consumer implements Runnable {
private final BlockingQueue<Integer> queue;
public Consumer(BlockingQueue<Integer> queue) {
this.queue = queue;
}
@Override
public void run() {
try {
while (true) {
// take方法会在队列空时自动阻塞
Integer item = queue.take();
System.out.println("Consuming: " + item);
Thread.sleep(100); // 模拟消费耗时
// 在实际场景中,可能有一个结束标志来跳出循环
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
方案二:使用 wait() / notifyAll() (经典)
import java.util.LinkedList;
import java.util.Queue;
public class ProducerConsumerWithWaitNotify {
public static void main(String[] args) {
SharedBuffer buffer = new SharedBuffer(10);
Thread producerThread = new Thread(() -> {
try {
for (int i = 0; i < 100; i++) {
buffer.produce(i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumerThread = new Thread(() -> {
try {
while (true) {
buffer.consume();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producerThread.start();
consumerThread.start();
}
// 共享缓冲区
static class SharedBuffer {
private final Queue<Integer> queue = new LinkedList<>();
private final int capacity;
private final Object lock = new Object();
public SharedBuffer(int capacity) {
this.capacity = capacity;
}
public void produce(int item) throws InterruptedException {
synchronized (lock) {
// 必须使用while循环来防止“虚假唤醒”
while (queue.size() == capacity) {
System.out.println("Buffer is full, Producer is waiting...");
lock.wait();
}
queue.add(item);
System.out.println("Producing: " + item);
// 唤醒可能在等待的消费者线程
lock.notifyAll();
}
}
public int consume() throws InterruptedException {
synchronized (lock) {
while (queue.isEmpty()) {
System.out.println("Buffer is empty, Consumer is waiting...");
lock.wait();
}
int item = queue.poll();
System.out.println("Consuming: " + item);
// 唤醒可能在等待的生产者线程
lock.notifyAll();
return item;
}
}
}
}
47.怎么提高效率?
-
使用更优的并发工具:
- 首先,应该优先使用
java.util.concurrent包提供的类。比如ArrayBlockingQueue或LinkedBlockingQueue。它们内部使用ReentrantLock和两个独立的Condition对象(一个用于生产者等待,一个用于消费者等待)。相比于synchronized和notifyAll(),这种方式可以做到精准唤醒——当生产者放入数据时,它只唤醒等待的消费者,而不会唤醒其他也在等待的生产者。这减少了不必要的线程唤醒和上下文切换,效率更高。
- 首先,应该优先使用
-
引入分片/分段思想 (Sharding):
- 如果单个队列的锁竞争实在太激烈,我们可以引入多个队列。比如,创建一个包含 4 个
BlockingQueue的数组。 - 生产者可以根据任务的哈希值,决定将任务放入哪个队列(
queue[task.hashCode() % 4].put(task))。 - 消费者也可以被分配去专门消费某个或某些队列。
- 这种“分而治之”的思想,将一把大锁的竞争,分散到了多把小锁上,并发度自然就提高了。这在像 Kafka 这样的消息队列系统中有广泛应用。
- 如果单个队列的锁竞争实在太激烈,我们可以引入多个队列。比如,创建一个包含 4 个
-
使用无锁数据结构 (Lock-Free):
- 这是最高级的优化。可以采用基于 CAS (Compare-And-Swap) 原子操作的无锁队列,比如
ConcurrentLinkedQueue。但请注意,ConcurrentLinkedQueue是无界的,它无法在队列满时阻塞生产者,所以需要我们手动在业务逻辑里控制生产速率,否则可能导致内存溢出。 - 业界顶级的解决方案,如 LMAX Disruptor 框架,就是基于一个环形缓冲区 (Ring Buffer) 和 CAS 操作,实现了极致的无锁高性能并发。
- 这是最高级的优化。可以采用基于 CAS (Compare-And-Swap) 原子操作的无锁队列,比如
48.引入循环呢?
49.生产的时候需要加锁吗?
50.在哪一步开始锁?
- 引入循环呢? (
while):在wait()调用外部必须使用while循环来检查条件(while (queue.size() == capacity))。这是为了防止“虚假唤醒”。有时线程可能在没有被notify的情况下被唤醒,如果用if,它会直接跳过检查往下执行,可能导致在缓冲区已满时继续生产,从而破坏程序逻辑。while循环能确保线程被唤醒后,再次检查条件,条件不满足则继续wait。 - 生产的时候需要加锁吗?:绝对需要! 因为“生产”这个动作包含了对共享资源
queue的修改(queue.add())和状态检查(queue.size()),这些操作必须是原子的。如果不加锁,多个线程可能同时判断队列未满,然后同时add,导致队列超出容量或内部数据结构损坏。 - 在哪一步开始锁?:在访问共享资源之前立即加锁,在访问结束后尽快解锁。 在我的代码里,
synchronized (lock)语句块的开始就是加锁的起点,它包裹了所有对queue的检查和修改操作。
51.唤醒消费者的时候 他还处于锁的状态 我怎么唤醒它
-
消费者线程发现队列为空,它执行
lock.wait()。此时会发生三件事:- a. 立即释放它持有的
lock锁。 - b. 将自己加入到
lock对象的等待队列 (Wait Set) 中。 - c. 线程进入
WAITING或TIMED_WAITING状态,暂停执行。 - 所以,回答问题 51:我(生产者)去唤醒它(消费者)的时候,它并不处于锁的状态,它早在调用
wait()的那一刻,就已经把锁释放了。否则,我(生产者)连synchronized代码块都进不来。
- a. 立即释放它持有的
-
生产者线程进入
synchronized代码块(因为它成功获取了被消费者释放的lock锁),它生产了一个数据,然后调用lock.notifyAll()。 -
notifyAll()的作用是将等待队列中的所有线程(包括那个消费者)移动到同步队列 (Synchronized Queue) 中,使它们的状态从WAITING变为BLOCKED。这些线程现在开始准备竞争锁。
52.我去唤醒你的时候 锁在我手上,然后唤醒之后 锁还在我手上,你什么时候能拿到锁?
-
- 生产者调用完
notifyAll()之后,锁还在它手上! 它会继续执行synchronized代码块里剩下的代码,直到走出这个代码块,锁才会被释放。 - 被唤醒的消费者线程此时处于
BLOCKED状态,它正在同步队列里排队,等待获取lock锁。 - 只有当生产者执行完毕,退出了它的
synchronized代码块,释放了锁之后,那个被唤醒的消费者线程才有机会去竞争并获取到这把锁。 - 一旦消费者成功拿到锁,它就会从当初调用
lock.wait()的地方继续往下执行,也就是从while循环的开头重新检查条件。
- 生产者调用完
总结一下锁的交接:wait() 是“先放手,再睡觉”;notify() 是“叫醒你,但我先不放手”;被唤醒的线程需要等到 notify() 的发出者“干完活,走出门口放手了”,才能去“抢那把钥匙进门”。
895

被折叠的 条评论
为什么被折叠?



