科大讯飞 (Android)面经

对于并发场景除了加锁,还有其他性能更好的方法吗?

除了传统的加锁,我们在并发场景下也可以考虑一些无锁或者轻量级同步的策略来提高性能。比如说,利用CAS(CompareAndSet)这种原子操作能在很多情境下替代锁,减少线程阻塞带来的开销。CAS是一种乐观锁的思想,它假设不会出现竞争冲突,如果真发生了冲突,再去重试,所以在竞争不激烈的时候性能往往比较高。

此外,读写锁也是一个不错的选择,如果我们场景中读操作远远高于写操作,采用读写锁可以让多个读者并行进行,不像普通的锁那样全部串行化。再比如,通过分离数据实现分段锁,也能够把大锁拆分成多个小锁,这样可以极大缩小锁竞争的区域,从而提升整体的并发性能。

另外,一些场景下,我们也可以采用无共享设计,通过线程局部存储或者消息传递的方式(比如 Actor 模型)来避免共享资源,这样就不需要加锁了。还有就是使用锁粗化和锁消除这些编译优化手段,对一些短时间有效的临界区进行优化,降低加锁本身的性能损耗。

Actor 模型其实是一种并发设计模式,它和我们平时讨论的锁机制不太一样。你可以把它理解为每个 Actor 就像一个独立的微型线程或者实体,它内部拥有自己的状态和行为,而且只能通过消息来与其他 Actor 进行通信。这样的话,就避免了直接共享内存,从而大大降低了并发竞争的问题。

具体来说,每个 Actor 会维护一个消息队列,当有消息发给它时,它会逐一处理这些消息。处理消息的过程是单线程的,所以你不用担心在 Actor 内部会有并发修改某个状态的问题。这种设计可以让程序的状态管理非常清晰,因为每个 Actor 的状态仅能由它自己来改变,而其他 Actor 只能发送消息请求。

举个例子,在 Kotlin 的协程库中就有提供 actor 的实现方式。你可以创建一个 actor,然后通过发送消息给它,让它按照顺序来处理,这样既能保证并发安全,又可以提高程序的整体响应能力,因为你不需要去上锁、解锁这些麻烦的操作。

总的来说,Actor 模型的核心思想就是“消息传递”,它把传统的锁竞争问题换成了消息队列的管理和调度。这样一来,程序在高并发的场景下更容易维护,而且也能利用异步和分布式架构处理更大规模的任务。

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.actor

// 定义要传递的消息实体
/*
sealed class Msg:
Sealed class是一种封闭类型,也就是说它的所有子类都会在这个文件中定义
这种方式可以确保所有可能的消息类型都是有限的,这对于处理消息分发非常有用,因为编译器能够在编译阶段检查到是不是漏掉了某个类型。
 */
sealed class Msg
object Ping : Msg()
data class CustomMsg(val content: String) : Msg()
object Stop : Msg()

// 创建一个 actor,该 actor 接收 Msg 类型的消息
/*
CoroutineScope:
actor 是一个扩展函数,它需要在某个 CoroutineScope 中调用。CoroutineScope 是协程的运行范围,它决定协程的生命周期。

actor<Msg>
actor 是一个泛型函数,这里的 <Msg> 代表这个 Actor 能够接收的消息类型
比如,如果你希望这个 Actor 接收的是 Int 类型的消息
那么你用 actor<Int>;如果希望接收复杂的消息对象,则定义一个类,比如 actor<MyMessage>。

actor 是一个协程构建器,它创建一个新的协程并返回一个 Channel,用于发送消息到这个协程。
channel 是一个协程的通信机制,它允许不同的协程之间进行消息传递。

is 是一个关键字,用于 类型检查,意思是“是否是某种类型”。它用来判断一个对象是否是某种具体类型的实例。
 */
fun CoroutineScope.actorExample() = actor<Msg> {
    for (msg in channel) {
        when (msg) {
            is Ping -> println("Received Ping")
            is CustomMsg -> println("Received message: ${msg.content}")
            is Stop -> {
                println("Stopping actor")
                channel.close() // 关闭 channel,结束 actor 的循环
            }
            else -> println("Unknown message")
        }
    }
}

fun main() = runBlocking {
    // 启动 actor,并保存其 Job
    val actor = actorExample()

    // 向 actor 发送消息
    actor.send(Ping)
    actor.send(CustomMsg("Hello Actor"))
    actor.send(Stop)

    // 关闭通道,确保不再发送消息
    actor.close()

    // 等待 actor 的协程完成
    (actor as Job).join()
}

为什么不直接推荐new一个线程

直接推荐使用 new 一个线程通常不是最佳选择,我的理解是主要从性能和管理角度考虑的。首先,直接每次需要并发任务的时候都创建一个新线程,会带来线程创建和销毁的开销,这在高并发或者频繁执行任务的场景下,会导致系统资源浪费。线程的创建不仅需要分配内存,还涉及到操作系统线程调度,一旦创建过多的新线程,可能还会导致调度混乱或者系统上下文切换频繁,进一步拖慢整个应用的响应速度。

其次,直接 new 线程在管理上也比较麻烦。比如,如果没有统一的渠道来管理这些线程,就很容易造成线程生命周期不受控。长时间运行的线程没有及时结束,就会消耗大量资源;而频繁创建又容易导致资源回收不及时的问题。相比之下,采用线程池或者基于协程这样框架,就能够很好地复用线程,避免重复的创建和销毁,管理起来也更有规律。通过线程池,我们还能限制并发线程的数目,防止因为并发过多导致的资源枯竭。

再者,Android 环境本身比较特殊,系统对资源管理要求更高。当开发者在主线程之外启动新线程时,如果不加注意,很可能会导致线程泄漏,甚至破坏应用的稳定性。现在流行的异步处理方式,比如使用 Kotlin 协程,不仅能更直观地管理并发任务,还隐式地帮我们管理了线程调度和生命周期,让代码更简洁明了。

总的来说,我觉得不直接 new 线程主要是为了提高系统的稳定性和性能,同时也方便管理线程的生命周期,避免一些隐患。所以在实践中,我会推荐使用线程池或现代的并发框架来处理多线程任务,而不是频繁直接 new 线程。

sleep和wait的区别

我个人觉得sleep和wait虽然看上去都是让线程暂停,但其实它们有很大区别。首先,sleep是线程级别的暂停,它是Thread类提供的静态方法,直接让当前线程“睡眠”一段时间,不管你当前是不是持有某个锁,睡眠期间都不会释放锁;另外,sleep是个静默方法,唤醒也只有过了规定的时间才会自动恢复继续执行,不会受到notify这种机制的影响。

而wait则完全不一样。wait方法是Object类中的,是在同步代码块里面用的,也就是说前提是必须获得某个对象的监视器锁。当你调用wait时,线程会主动放弃持有的锁,进入该对象的等待队列,直到另一个线程调用notify或notifyAll后,等待的线程才有机会重新竞争到锁继续执行。这种机制主要用于线程协作,保证多个线程之间能够有序地等待或者唤醒,避免死锁和竞争问题。notify 是 Java 中线程通信的一个重要方法,它属于 Object 类,主要用来唤醒一个正在等待该对象锁的线程。

简单来说,sleep更多是用于简单的延时,不会释放锁,而wait则是为了让线程等待其他线程的通知,并且在等待时会释放锁。这样设计的原因就是为了提高线程交互的灵活性和协作性。当然,它们的使用场景也完全不同,我在实际中会根据业务需要选择合适的方法。

Looper死循环为什么不会卡死

虽然Looper内部是一个“死循环”,但这个死循环是专门设计用来处理消息队列中的消息,不是那种无限占用CPU资源的死循环。简单说,Looper一直在等待消息进入队列,然后处理后再进入下一次循环。它的实现实际上是在等待到有新的消息到来或被唤醒,所以它不会一直不停地占用资源。

另外,Looper内部会调用native方法进行低级的等待,使用的是操作系统提供的高效阻塞机制,比如等待事件的到来。当没有消息时,它不会不断地轮询,而是处于一种休眠状态,直到有新的消息到来或者被唤醒。这样设计可以保证系统不会因为这个循环而卡死,因为它实际上是被动等待,而不是主动占用CPU资源。

再加上,整个消息循环是在主线程中执行的,而主线程还负责用户的输入事件和界面的绘制。如果Looper一直卡住了,那确实会引起ANR(应用无响应)。但是系统设计时就考虑到了这一点,保证消息循环快速响应消息,而且只要我们不把耗时任务放到主线程,消息循环就能高效稳定地运转。

所以,从整体上看,Looper的“死循环”其实是经过精心设计和优化的,不会一味地消耗资源,而是能高效地保持线程的待命状态,及时处理消息,确保应用的流畅运行。

native方法其实是Java或者Kotlin里声明的一种方法,名字前面加了native关键字,但它自己并不在Java代码里面实现,而是交给底层的C或C++语言来实现。简单地说,它就是Java和底层平台之间的一座桥梁,通过JNI(Java Native Interface)把Java层调用转发到操作系统或者其他高性能的原生代码上来处理任务。

从实际应用上来说,我们常用native方法来处理一些对性能要求特别高、或者需要直接访问硬件、系统资源的场景。因为有时候用纯Java写代码无法做到足够高效,或者Java库没有提供你需要的接口,这时候就会利用底层编程语言实现更细粒度的控制,然后通过native方法调用它们。

比如在Android系统里,很多绘图、视频处理或者系统级别的底层操作都是通过native方法来完成的。这样设计能让Java层代码保持高层逻辑清晰的同时,又能利用C/C++的高效性能来完成具体的工作。

总体上,native方法既能提高应用性能,又能扩展Java和Kotlin不可及的功能,但也需要注意它的使用成本,比如内存管理、调试复杂性等问题。所以在开发过程中,我们会慎重考虑什么时候采用native方法,尽量在有需求的场景下用这一技术。

线程之间如何通信

第一种就是利用共享数据和同步机制,比如说用 synchronized 配合 wait 和 notify。举个例子,如果两个线程需要协调操作共享资源,一个线程可以调用 wait 让自己进入等待状态,另一个线程在操作完共享数据后调用 notify 或 notifyAll 来唤醒等待的线程。这种方式比较原始,但要注意一定要在同步块里面使用,否则容易引发一些线程安全问题。

另外一个更现代一点的方式是使用线程安全的数据结构,比如说 BlockingQueue。BlockingQueue 的好处是,一个线程可以往队列里放入数据,而另一个线程则可以阻塞地从队列中获取数据,这就实现了很自然的生产者/消费者模式。这样就避免了低级别的 wait/notify 机制可能引起的错误,而且代码也更清晰、易维护。

在 Android 开发中,我们经常使用 Handler 和 Looper 来在主线程和子线程之间传递消息。主要思想是让子线程把任务或者要更新UI的消息发送到主线程的消息队列中,由主线程的 Looper 循环不断取出并处理,这样就能确保UI操作在主线程上进行,又避免了直接跨线程操作带来的问题。Handler 的好处是不仅能带来线程间安全的数据传递,同时也能处理延时或者定时任务,让线程之间的协作显得非常自然。

还有一种方式就是利用现代的 Kotlin 协程,通过 Channel 来实现类似消息队列的功能,让协程之间可以发送或接收数据,这其实也是一种轻量级的线程通信方式。Channel 不仅简化了异步通信流程,还能防止回调地狱这种情况,代码更易读和维护。

总的来说,线程间通信的方式可以根据实际场景来选择。如果是简单场景可以考虑使用同步锁和 notify/wait;如果是复杂场景或者需要更高层次的抽象,那么消息传递机制比如 Handler、BlockingQueue 或者 Kotlin 的 Channel 通常会更好。

rxjava和协程解决了什么问题

RxJava 和协程其实是在解决异步编程和多线程处理上很多麻烦问题。比如,如果你用传统的线程或回调方式来处理异步任务,代码可能会变得非常混乱,容易出现回调地狱或者线程管理混乱的问题。RxJava 的出现,把异步操作、事件流转换和组合变得更系统化,它让我们可以使用响应式编程的思想,把各种异步数据流以链式操作的方式进行组合、转换和过滤,错误处理也统一起来,这样一来,它不仅有效规避了回调地狱的问题,还提高了代码的灵活性和可维护性。

不过,RxJava 的学习曲线确实有点陡峭,而且在错误处理和线程切换上,有时候写起来也不够直观。这也是协程出现的原因之一。协程其实就是为了解决异步编程的烦恼而设计的,它让我们的异步代码看上去像同步代码一样直观。你可以用 suspend 函数来挂起当前任务,让编写异步操作的代码逻辑更清晰,省去了大量回调和线程切换的样板代码。尤其在 Android 开发中,协程通过轻量级线程的方式来处理异步任务,不仅降低了线程创建的开销,还能更好地管理任务的生命周期,防止因为线程泄露带来的问题。

总的来说,两者都致力于解决传统异步编程模式下的复杂性和资源管理问题,不过各自有侧重点:RxJava 更多地偏向于响应式流、数据流转化和组合操作,而协程则侧重于简化异步调用,让异步代码写起来跟同步代码一样顺畅。

"回调地狱"(Callback Hell)是指在异步编程中,由于嵌套了过多的回调函数,导致代码结构变得非常复杂、难以阅读和维护的一种现象。

MVVM和MVP的区别,有什么好处

先说MVP吧。MVP模式中有三个角色:View、Presenter和Model。View负责UI展示,Presenter处理所有业务逻辑以及跟Model的数据交互,View跟Presenter之间的通信主要是通过接口进行调用。这样设计最大的好处就是把业务逻辑从界面中抽离出来,Presenter也比较容易单元测试,因为它不涉及具体的UI操作。不过,MVP模式也存在一个问题,就是View的更新需要Presenter手动调用,一旦业务逻辑比较复杂,这些回调可能会变得冗长,而且View和Presenter之间的绑定需要开发者自己管理。

而MVVM的引入,就是希望让这一层数据同步能够更自动化。在MVVM中,我们把Presenter换成了ViewModel,ViewModel不仅负责业务逻辑和数据处理,还通过数据绑定(比如在Android中用DataBinding或者LiveData等)直接和View进行双向绑定。当数据更新时,View会自动反应出来,从而大大减少了手动的UI更新代码。这种方式使得代码结构更清晰,人员职责分明:View只负责渲染,ViewModel只负责业务逻辑和状态,不需要关注怎么更新UI,数据变化了自然就更新。

另外,在MVVM中,由于通过数据绑定把状态和展示进行解耦,不仅减少了模板代码,而且也让开发起来更直观,尤其在需要复杂动态更新UI的场景下非常便利。同时,对于单元测试也比较方便,因为我们其实只需要针对ViewModel进行测试,不用担心UI层的细节。

总的来说,MVP更强调Presenter作为中间层,职责清晰但有一些模板代码,适合对架构要求疏松一些的场景。而MVVM更多地借助数据绑定和响应式编程的思想,能让UI层的更新变得自动化,从而减少我们手动调用更新代码的可能性。

什么是内存泄漏然后,安卓中有哪些场景

在Android开发中,内存泄漏其实挺常见的,主要有以下几个场景:

  1. 活动(Activity)和Fragment泄漏:最常见的是当Activity或者Fragment被销毁后,但其中一些内部引用,比如匿名内部类、静态变量或线程未释放引用导致他们的生命期长于Activity,这样就会引起Activity无法被垃圾回收。

  2. 上下文泄漏:这跟Activity泄漏有点类似,很多时候我们会因为不小心保存了Activity的引用,比如放到全局的单例对象中或者静态变量里,而这些全局对象会一直持有这个引用,导致上下文没法及时释放。

  3. Handler泄漏:因为Handler通常是用来传递消息的,有时候我们在Activity里面使用Handler,如果Handler是一个非静态内部类,它会隐式持有对外部类的引用。如果Activity销毁了,但Handler的消息队列中还有未处理的消息,就会导致Activity无法被回收。

  4. 注册的观察者、监听器未撤销:比如我们注册了广播接收器、ContentObserver或者某些回调,但在退出或者销毁时忘记注销,这些对象同样会持有Activity或Context的引用。

  5. 单例模式的不当使用:单例如果不注意内存持有,比如把Activity或View实例传递给单例类,这也会导致泄漏。

总结来说,内存泄漏在Android中是一个比较常见的问题,主要源自于引用管理不当。我们需要在设计上注意及时清理,比如在Activity销毁时解除所有引用、使用静态内部类以及弱引用、适当注销回调和监听器。

什么是内存抖动

内存抖动基本上是指在应用运行过程中,因为对象频繁地被创建和销毁,导致内存分配和垃圾回收操作过于频繁,从而引起性能上的不稳定现象。这种情况就像是内存不断地在“抖动”,每次创建或释放对象都会触发一次GC,而GC暂停可能会导致应用界面出现卡顿或者响应不及时的情况。

在Android开发中,如果我们不注意内存的管理,比如说不停地创建短生命周期对象或者有一些不恰当的数据结构操作,这些都会让内存抖动更加明显。内存抖动的根本问题在于系统需要不断地进行垃圾回收操作,而每次垃圾回收可能都会让CPU暂停一段时间,这影响了应用的流畅度。要解决这个问题,我们通常会考虑优化对象的复用,减少不必要的对象创建,比如用对象池或者考虑使用静态资源的方式来降低内存分配频率,从而减缓GC的压力。

总的来说,我认为内存抖动还是一个比较实际的问题,它不仅影响性能,还可能引起用户体验上的问题,所以良好的内存管理和合理的内存布局规划都是非常关键的。

什么是过度绘制

过度绘制实际上是指在一帧画面中,系统对同一个像素点做了多次绘制操作。简单来说,就是在屏幕上“画重了”。比如说,你的界面布局里如果用了多个容器,每个容器还有背景色或者背景图,这样就可能导致底层同一块区域被多次渲染。虽然在视觉上你看到的只有一层效果,但是事实上系统可能已经多次走访和渲染了那块区域。

这种情况会给性能带来负面影响,特别是在低端设备或画面比较复杂的时候,多次绘制会消耗更多CPU和GPU资源,影响应用的流畅性和电池续航。因此,我们平时在开发中会特别注意控制布局的嵌套层级,尽量避免不必要的背景叠加。其实就是在设计界面的时候,要精简布局,尽量让每一层都有明确的分工,避免因为多个视图重叠而导致系统需要多次绘制同一个区域。这样不仅能提高渲染效率,也能改善整体的用户体验。

在开发过程中什么时候会出现UI卡顿的现象

我觉得在实际开发中,UI卡顿一般都是因为在主线程上做了过多耗时操作导致的。比如说,如果在主线程中处理比较复杂的数据计算、网络请求或者数据库操作,这些都会阻塞UI线程,导致界面响应变得不流畅。另外,如果在绘制界面时存在复杂的布局嵌套、多层次的过度绘制,甚至在绘制过程中做了大量的自定义渲染逻辑,都有可能影响绘制的效率,引起帧率下降和卡顿现象。

还有一些情况下也会引发卡顿,比如说在动画过程中,如果配合了过度的计算或者触发了意外的布局变更,同样会让动画的执行不够流畅。此外,一些错误的资源管理,比如频繁地加载大图片或者内存泄漏导致GC频繁触发,也会让系统在垃圾回收时出现顿挫,进而表现为UI卡顿。

总的来说,UI卡顿的问题基本都集中在主线程被阻塞或者被过多任务干扰。所以在开发中,我们经常会把耗时操作放到子线程中执行,并且在设计布局时尽量简化层次,避免不必要的重新绘制和布局操作,从而让主线程保持流畅,用户体验才能更好。

HTTPS和HTTP的区别

HTTP是明文传输,也就是说所有数据都是直接发送出去的,这就存在数据被窃听和篡改的风险。而HTTPS在传输之前会先建立一个加密通道,主要是利用SSL/TLS来加密数据,所以它在安全性上要好很多。比如说,在使用HTTPS时,数据从客户端到服务端过程中,如果被黑客截获,也是看不懂的,因为解密需要相应的密钥和证书。

除了安全,HTTPS还有一个重要的好处就是数据完整性。通过加密,既保证了数据不会在传输过程中轻易被篡改,同时也能防止中间人攻击。因为客户端在连接的时候会验证服务端的数字证书,如果证书不匹配或者已经过期,就会提示安全警告,这样可以有效避免用户访问伪造的网站。

当然,HTTPS也有一些额外的开销,比如说建立SSL/TLS连接时会有握手过程,会稍微增加一些延时,但现代网络和硬件水平都已经可以把这部分开销降到很低,所以大部分应用场景下这个加密过程带来的性能影响是可以忽略的。

还有,在开发过程中,如果是内部数据传输不涉及敏感数据,也有可能用HTTP,但如今安全意识越来越高,哪怕是看似不敏感的数据也存在风险,所以很多场景基本上都会选择HTTPS。总的来说,HTTPS在保护数据隐私和提升用户安全体验方面具有非常重要的作用,而HTTP更多的是一种不加密的简单通信协议。

安卓端如何获取CA证书,以及获取后是否要保存到客户端

在实际项目中,我们通常不会在运行时从外部临时获取CA证书,而是提前把需要的证书内置在应用中(比如放在raw资源目录下),或者依赖系统的信任库。

首先,从获取方式上看,我们常常有两种情况:一种是使用系统自带的CA证书库,这个一般不需要我们手动处理,系统会自动根据预设的信任列表来验证服务器的证书;另外一种情况是我们自己做证书绑定(比如证书固定 pinning),这种情况下我们需要将服务器的CA证书或者服务器端的公钥预先嵌入到应用中。这里的做法一般是在项目构建时把证书文件放到资源里,然后在程序启动或网络请求初始化时读取,把这些证书加载到对应的信任管理器当中。

再来说下“获取后是否要保存到客户端”。如果你是从服务器动态获取CA证书这种场景,一般是不建议把动态获取到的证书保存到本地。因为证书一旦过期或者需要更新,动态获取操作其实会带来管理和安全风险。通常建议是固定的证书就内置在程序中或者通过安全渠道获取。如果你想做证书固定,那么提前打包在应用中就足够了,运行时加载到内存进行校验,这样既保证了安全,又避免了频繁的网络请求或者不必要的写磁盘操作。

另外,安全方面考虑,如果我们需要动态更新证书信息,那么可以考虑加密存储,但也得平衡密钥管理和复杂度。总的来说,除非有特殊需求,一般我们采用提前打包或者直接依赖系统证书来实现安全验证,不需要在运行时临时保存或者持久化下载的CA证书。这样做既降低了安全风险,也能更好地控制证书的更新策略。

安卓内存和磁盘缓存机制

内存缓存主要是利用RAM来保存数据,比如常见的Bitmap缓存、网络请求返回的数据等。因为内存的读写速度非常快,所以用在这儿可以让界面响应更迅速。但内存毕竟有限,Android系统在内存资源紧张时也会清理掉内存缓存,因此一般只用来保存短期内会重复使用的数据。而且,为了防止内存泄漏和抖动,我们会根据数据的生命周期以及应用的内存要求来设计合适的缓存策略,比如采用LRU算法来淘汰一些不常用的内容。

磁盘缓存则是把数据存储在SD卡或者内部存储中,虽然读写速度比内存低,但容量通常大得多,所以适合存储一些长期有效、需要持久化的数据,比如图片、网页缓存或者其他网络资源。通常我们会在磁盘缓存中设置一个大小限制,并且实现一些缓存清除策略,防止缓存文件无限制增长。好处在于应用重新启动后仍然可以加载之前缓存的数据,也避免了每次都去服务器重新请求。

总的来说,内存缓存和磁盘缓存其实是互补的:内存缓存保证了瞬时的高响应性,而磁盘缓存则提供了数据持久化和容量上的优势。在具体设计时,我通常会将一些频繁访问的小数据放在内存里,较大的数据文件或者不需要频繁更新的数据则存放在磁盘上,再经过一些策略协调,比如内存不足时升级磁盘缓存的数据加载方式,整体上提升用户体验和减少网络开销。

RAM(Random Access Memory),即“随机存取存储器”,是一种计算机硬件组件,用来存储数据和程序指令。

  • RAM 的“随机”指的是,数据可以在内存的任意位置被快速访问,而不需要像磁盘那样顺序读取。这种随机访问能力使得 RAM 的读写速度非常快,远远快于传统的存储设备(比如硬盘)。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值