rokevin
移动
前端
语言
  • 基础

    • Linux
    • 实施
    • 版本构建
  • 应用

    • WEB服务器
    • 数据库
  • 资讯

    • 工具
    • 部署
开放平台
产品设计
  • 人工智能
  • 云计算
计算机
其它
GitHub
移动
前端
语言
  • 基础

    • Linux
    • 实施
    • 版本构建
  • 应用

    • WEB服务器
    • 数据库
  • 资讯

    • 工具
    • 部署
开放平台
产品设计
  • 人工智能
  • 云计算
计算机
其它
GitHub
  • 快手 面试

  • Handler 机制是如何工作的?
  • Okhttp 的优点和拦截器的作用
  • 优点:
  • 拦截器的作用:
  • Okhttp 里面的线程池参数是如何配置的?
  • 如果开一个 for 循环去不断给 Okhttp 发请求,会奔溃吗?为什么?
  • 为什么 okhttp 将线程池的队列前移?
  • 如何自定义一个 View?
  • View 的绘制流程是怎样的?
  • View 绘制屏幕刷新率和帧率控制是如何实现的?
  • 事件是怎么从硬件层传递到应用层的?
  • DOWN 事件有几个坐标点,MOVE 事件呢?
  • 事件分发详细## 的## 讲(三个核心函数以及整体过程),详细## 的## 讲下 dispatchTouchEvent,子 View 如何阻止父 View 拦截事件,Action_Cancel 什么时候会发生?
  • Activity 的生命周期是怎样的?
  • Activity 启动模式有哪些?SingleTop 和 standard 启动模式下,生命周期回调有何不同?
  • onStart 和 onResume 的区别是什么?
  • 多线程通信的方式有哪些?
  • 为什么要有线程池?
  • 发线程数量越多性能越好吗?
  • 如何设计一个线程池?(结合原理讲)
  • 线程池有 CPU 密集型和 IO 密集型线程池,他们的区别是什么?
  • 线程并发相关,悲观锁,乐观锁的区别?
  • Synchonized 和 AtomicInteger 的区别?
  • 协程调度器是如何工作的?
  • Java 中的值传递和引用传递有什么区别?
  • 自动装拆箱会遇到什么问题?
  • 了解的加密机制有哪些?
  • 什么是非对称加密,什么是对称加密?
  • loop 方法为何不会造成 ANR?
  • 是否能在主线程更新 UI?
  • 同步屏障机制是什么?
  • Android 布局优化有哪些方法?
  • LinearLayout 和 RelativeLayout 的区别,优缺点,层级嵌套等等等
  • ConstrantLayout 讲讲特点
  • FrameLayout 了解么?
  • HashMap 底层实现原理是什么?
  • HashMap(红黑树的时间效率为什么是 logn,怎么算出来的?)
  • ConcurrentHashMap 的实现原理是什么?
    • 说说内存优化有哪些方法?
    • OOM 在什么情况下发生?
    • 怎么在线上收集 OOM 和内存泄漏?
    • Leakcanary 的原理?
    • tcp 和 udp 的区别是什么?
  • OSI 七层模型,tcpip 四层模型是什么?
  • TCP 三次握手四次挥手的过程是怎样的?
  • HTTP 和 HTTPS 的区别是什么?
  • 创建线程的方式有哪些?
  • 进程和线程区别是什么?
  • 死锁是如何发生的?
  • volatile 关键字的作用是什么?
  • 代码层面怎么去做瘦身优化?
  • 几种热修复方案的原理及优缺点?
  • 虚拟机栈中为啥会有局部变量表?它的设计初衷是什么?
  • 四大引用的区别是什么?
  • GC 内存回收机制?Android 和 Java 中有什么区别?
  • so 的编译过程,静态库和动态库的区别,动态链接是什么?
  • so 文件的编译过程
  • 静态库和动态库的区别
  • 动态链接
  • 使用过哪些动画,属性动画和 View 动画的区别在哪里,View 动画的原理(ValueAnimator 和 ObjectAnimator 的区别)?
  • 属性动画和 View 动画的区别
  • View 动画的原理(ValueAnimator 和 ObjectAnimator 的区别)
  • Fragment 的生命周期是怎样的?
    • 四大引用的区别是什么?
  • 强引用(Strong Reference):
  • 软引用(Soft Reference):
  • 弱引用(Weak Reference):
  • 虚引用(Phantom Reference):
    • GC 内存回收机制?Android 和 Java 中有什么区别?
    • so 的编译过程,静态库和动态库的区别,动态链接是什么?
  • 存储与使用方式: 静态库在程序链接阶段,会把库文件中的代码和数据全部复制到最终的可执行文件当中。比如有一个静态库包含了几个函数,一旦某个程序链接了这个静态库,那这些函数的代码就实实在在地嵌入到了程序的可执行文件里,后续程序运行时就不再依赖这个静态库文件本身了。而动态库则不同,它的代码和数据不会被复制到可执行文件里,是在程序运行过程中,当需要调用库中的函数或者访问库中的数据时,才会去加载动态库,多个程序可以共享同一个动态库,节省了内存和磁盘空间。
  • 可移植性与更新便利性: 静态库的优点在于可移植性强,因为可执行文件已经包含了所有需要的代码,拿到其他环境下基本都能直接运行,不受外部库文件存在与否的影响。然而它的缺点是一旦静态库有更新,哪怕只是修改了其中一个小函数,都需要重新编译整个程序来更新应用。动态库的优势在于节省资源以及更新方便,多个应用可以共用一个动态库,只要更新了动态库文件,所有使用它的应用都能受益,不过它对运行环境依赖较强,要是运行时找不到对应的动态库或者动态库版本不匹配等情况出现,程序就可能无法正常运行。
  • 内存占用情况: 静态库会使可执行文件的体积增大,因为它把相关代码都复制进去了,而动态库本身不会增加可执行文件大小,多个程序共用还能在内存中共享,所以在大规模应用场景下,动态库在内存利用方面更有优势。
    • 使用过哪些动画,属性动画和 View 动画的区别在哪里,View 动画的原理(ValueAnimator 和 ObjectAnimator 的区别)?
  • 作用对象范围: View 动画主要是针对 View 进行操作,通过对 View 的内容进行变换来展现动画效果,例如对一个 TextView 做平移动画,其实是在屏幕上改变其显示位置的视觉效果,而 TextView 本身的布局属性等并没有真正改变。属性动画则可以对任何对象的属性进行动画,不限于 View,只要对象的属性有对应的get和set方法(或者符合一定的属性访问规则),就能实现动画效果,像对一个自定义的实体类对象的某个自定义属性进行动画改变是可行的。
  • 动画效果持久性: View 动画在执行完后,View 会恢复到原来的状态,也就是动画只是视觉上的呈现,没有改变 View 的实际属性值。而属性动画会实实在在地改变对象的实际属性,动画结束后对象的属性就停留在动画结束时的值,比如通过属性动画改变了一个按钮的透明度,动画结束后按钮的透明度就保持在最后设置的值上。
  • 动画扩展性: 属性动画的扩展性更强,它可以通过自定义 Evaluator(估值器)来实现对属性变化的自定义计算。例如,想要实现一种特殊的颜色渐变动画,从一种颜色逐渐过渡到另一种颜色,就可以自定义颜色的 Evaluator 来精准控制颜色变化过程,而 View 动画在这方面的自定义能力相对较弱,基本只能按照既定的几种类型和参数来实现动画。
    • Fragment 的生命周期是怎样的?
    • Service 的生命周期是怎样的?
    • BroadCastReceiver 的源码看过么?
    • Message.obtain () 有什么好处,为什么不使用 new Message?
    • 算法题:100000 个数字中,对前 10000 小的数字进行排序
    • 知道哪些设计模式?
    • 说一下建造者模式的核心,并写一下(代码题)
    • 写一下单例并讲一下,经典双重校验锁

快手 面试

Handler 机制是如何工作的?

Handler 机制在 Android 开发中用于在不同线程之间进行通信,特别是用于在子线程中更新 UI。

Handler 主要包含四个关键部分:Handler 对象、Message、MessageQueue 和 Looper。

首先是 Looper。Looper 是一个消息循环,它会不断地从消息队列(MessageQueue)中获取消息并进行分发。在 Android 的主线程(UI 线程)中,系统会自动创建一个 Looper 对象,这个 Looper 会一直循环,保证 UI 能够持续地接收和处理消息。Looper 通过loop方法来实现循环,在这个循环中,它会调用messageQueue.next()方法来获取下一个消息,如果有消息,就会将消息分发给对应的 Handler 进行处理。

MessageQueue 是一个消息队列,它用于存储消息(Message)。消息可以是各种类型的信息,比如在子线程中获取到的数据,需要在主线程中更新 UI 的指令等。消息在队列中按照一定的顺序排列,一般是按照时间先后顺序或者优先级顺序。当一个消息被放入消息队列后,它会等待 Looper 来获取并分发。

Message 是具体的消息对象。它包含了一些重要的信息,如消息的类型(用于区分不同用途的消息)、消息的内容(可以通过obj属性来传递数据)、消息的目标 Handler(用于确定由哪个 Handler 来处理这个消息)等。例如,在一个网络请求的子线程中,当获取到网络数据后,可以创建一个 Message 对象,将数据作为消息的内容,然后将这个消息发送到主线程的消息队列中。

Handler 对象是用于发送和接收消息的接口。在创建 Handler 时,可以指定一个 Looper 对象,如果不指定,默认会关联到当前线程的 Looper(在主线程中就是已经存在的 Looper)。Handler 有两个重要的方法用于发送消息,一个是sendMessage方法,另一个是post方法。sendMessage方法用于发送一个 Message 对象,而post方法用于发送一个 Runnable 对象,这个 Runnable 对象会在 Handler 所关联的线程(通常是主线程)中执行。当 Handler 发送消息后,消息会被放入 Looper 所关联的消息队列中。

当 Looper 从消息队列中获取到一个消息并且发现这个消息的目标 Handler 是某个特定的 Handler 时,就会调用这个 Handler 的handleMessage方法。在这个方法中,可以根据消息的类型和内容来进行相应的处理。例如,在handleMessage方法中更新 UI,将从子线程获取到的数据显示在屏幕上。这样,通过 Handler 机制,就可以实现在子线程和主线程之间安全地传递消息并且更新 UI。

Okhttp 的优点和拦截器的作用

优点:

Okhttp 是一个高效的 HTTP 客户端,用于 Android 和 Java 应用程序。它具有许多优点。首先,它支持同步和异步请求。对于同步请求,代码会在请求完成后才继续执行下一行,这种方式适合简单的顺序执行场景。而异步请求则允许程序在等待网络响应的同时进行其他操作,不会阻塞主线程,能有效提升用户体验,避免应用出现卡顿。

它的 API 设计简洁易懂。例如,构建一个简单的 GET 请求只需要很少的代码步骤。通过链式调用可以方便地设置请求头、请求体等各种参数。比如:

OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder()
   .url("https://example.com/api")
   .build();
Call call = client.newCall(request);

Okhttp 在连接池管理方面表现出色。它会自动复用连接,减少了建立新连接的开销。当应用频繁地请求同一个域名下的资源时,连接池能够快速地提供可用的连接,而不是每次都重新建立 TCP 连接,这大大提高了网络请求的效率。

它还支持多种数据格式,像常见的 JSON、XML 等。并且能够很好地处理网络异常。当网络出现问题,如连接超时、无法连接到服务器等情况时,Okhttp 会抛出相应的异常,开发人员可以在代码中捕获这些异常并进行合适的处理,比如提示用户检查网络设置或者重试请求。

拦截器的作用:

Okhttp 的拦截器是一个非常强大的功能。拦截器分为应用拦截器和网络拦截器。

应用拦截器可以在请求发出之前和响应接收之后对数据进行处理。在请求发出之前,我们可以通过拦截器添加统一的请求头,比如添加认证信息。假设我们的应用需要在每个请求中添加一个自定义的 Token 来进行用户认证,就可以通过应用拦截器来实现:

class AuthInterceptor implements Interceptor {
    @Override
    public Response intercept(Chain chain) throws IOException {
        Request originalRequest = chain.request();
        Request.Builder builder = originalRequest.newBuilder();
        builder.header("Authorization", "Bearer " + getToken());
        Request newRequest = builder.build();
        return chain.proceed(newRequest);
    }
}

在这个例子中,拦截器获取到原始请求,然后添加了一个名为 “Authorization” 的请求头,其值包含了用户认证的 Token,之后再通过chain.proceed()方法将修改后的请求发送出去。

应用拦截器还可以用于对响应数据进行预处理。例如,当服务器返回的数据是经过加密的,我们可以在应用拦截器中对数据进行解密操作,然后再将解密后的数据传递给业务逻辑层进行处理。

网络拦截器主要用于监控和修改 Okhttp 与服务器之间的网络交互。它可以查看和修改请求和响应的原始字节流。例如,网络拦截器可以用于记录网络请求和响应的详细信息,包括请求的 URL、请求方法、响应状态码等。这对于调试网络问题和性能分析非常有用。

另外,网络拦截器还可以用于处理一些网络层的特殊情况。比如在重定向的时候,我们可以通过网络拦截器来决定是否允许重定向,或者修改重定向的目标 URL。

Okhttp 里面的线程池参数是如何配置的?

Okhttp 中的线程池主要用于异步请求的执行。在 Okhttp 中,线程池的配置是通过Dispatcher类来完成的。

Dispatcher类有一些关键的参数用于控制线程池的行为。其中一个重要的参数是maxRequests,它表示同时执行的最大请求数量。这个参数的默认值是 64。例如,如果我们设置maxRequests为 32,那么 Okhttp 在同一时间最多只会执行 32 个异步请求,其余的请求会在等待队列中等待。

另一个参数是maxRequestsPerHost,它用于限制对每个主机的最大请求数量。这个参数的默认值是 5。这意味着对于同一个主机,Okhttp 最多同时执行 5 个请求。这种限制有助于防止对单个主机的过度请求,避免服务器过载和性能下降。

Okhttp 的线程池是基于 Java 的ExecutorService实现的。在内部,Dispatcher维护了一个ExecutorService对象,用于执行异步请求。当我们调用Call.enqueue()方法发起一个异步请求时,Dispatcher会将这个请求添加到合适的队列中,并根据线程池的状态和上述参数来决定何时执行这个请求。

线程池中的线程数量不是固定的,它会根据请求的负载动态调整。当有大量请求到来时,线程池会创建新的线程来处理这些请求,但会受到maxRequests等参数的限制。当请求处理完成后,线程会被回收,以避免资源浪费。

在实际应用中,我们可以根据应用的具体需求来调整这些参数。如果应用主要是和少数几个主机进行交互,并且这些主机的服务器性能较强,我们可以适当增加maxRequestsPerHost的值,以提高请求的并发度。但如果应用需要和大量不同的主机进行交互,或者服务器的性能有限,那么可能需要降低maxRequests和maxRequestsPerHost的值,以避免对服务器造成过大的压力。

同时,我们也可以通过自定义Dispatcher来实现更复杂的线程池配置。例如,我们可以创建一个继承自Dispatcher的类,然后在这个类中重新定义maxRequests和maxRequestsPerHost等参数,最后将这个自定义的Dispatcher设置到OkHttpClient中。这样就可以根据应用的特殊需求来灵活配置线程池。

如果开一个 for 循环去不断给 Okhttp 发请求,会奔溃吗?为什么?

如果在一个 for 循环中不断地给 Okhttp 发送请求,是否会崩溃取决于多种因素。

首先,Okhttp 有自己的分发机制来处理请求。它内部有就绪队列和等待队列。当发起一个请求时,请求会首先被放入等待队列。Dispatcher会根据当前正在执行的请求数量(受maxRequests参数限制)和每个主机正在执行的请求数量(受maxRequestsPerHost参数限制)来决定何时将请求从等待队列移到就绪队列,就绪队列中的请求会被分配到线程池中的线程进行处理。

如果在 for 循环中发送请求的速度过快,超过了 Okhttp 的处理能力,等待队列会不断增长。在极端情况下,如果等待队列无限制地增长,可能会导致内存不足,从而使应用崩溃。这是因为每个请求对象都会占用一定的内存空间,大量的请求堆积在等待队列中会消耗大量的内存。

然而,Okhttp 本身也有一些机制来避免这种情况。如前面提到的maxRequests和maxRequestsPerHost参数,它们会限制同时执行的请求数量和对每个主机的请求数量。当达到这些限制时,新的请求会在等待队列中等待,直到有空闲的线程或者满足其他执行条件。

另外,网络状况也会对这种情况产生影响。如果网络速度很慢,请求的响应时间会变长,这会导致线程池中的线程被长时间占用,进一步降低了请求的处理速度。在这种情况下,等待队列也更容易堆积请求。

同时,服务器的响应能力也是一个关键因素。如果服务器无法及时处理大量的请求,可能会返回错误响应或者超时。Okhttp 会根据服务器的响应来处理这些情况,但如果错误响应过多或者超时情况频繁发生,也可能会对应用的稳定性产生影响。

为了避免这种情况导致崩溃,我们可以采取一些措施。一是合理设置maxRequests和maxRequestsPerHost参数,根据服务器的性能和网络状况来调整这些参数,确保请求的并发度在一个合理的范围内。二是可以在应用层对请求进行限流。例如,我们可以使用一些算法来限制在单位时间内发送的请求数量,比如令牌桶算法或者漏桶算法,这样可以避免请求的发送速度过快。

为什么 okhttp 将线程池的队列前移?

Okhttp 将线程池的队列前移主要是为了提高请求的响应效率和资源利用率。

在传统的线程池模型中,当有新的任务(在 Okhttp 中即 HTTP 请求)到来时,任务会先进入一个等待队列,然后线程池中的线程会从这个等待队列中获取任务来执行。这种方式可能会导致一些问题。例如,当等待队列很长时,新的任务可能需要等待很长时间才能被执行,这会导致请求的响应延迟。

Okhttp 通过将队列前移,采用了一种更灵活的分发机制。它会根据请求的状态和服务器的响应情况来动态地安排请求进入就绪队列。例如,当一个请求对应的服务器响应很快时,Okhttp 可以更快地将下一个请求从等待队列移到就绪队列,而不是像传统线程池那样严格按照先来后到的顺序从等待队列中获取任务。

这种方式可以更好地利用网络带宽和服务器资源。假设我们正在从一个服务器下载多个文件,并且服务器有足够的带宽来同时处理多个请求。如果按照传统的线程池队列方式,可能会因为等待队列中前面的请求处理较慢(例如,某个请求需要下载一个很大的文件)而导致后面的请求无法及时得到处理,即使服务器有足够的资源来同时处理多个请求。而 Okhttp 的队列前移机制可以根据服务器的实际响应情况,及时地将其他请求移到就绪队列进行处理,从而充分利用服务器的带宽和资源。

另外,Okhttp 的这种机制也有助于应对网络的动态变化。在网络状况良好时,它可以更快地处理请求,提高并发度。而当网络出现波动或者服务器响应变慢时,它可以通过调整请求在队列中的位置,避免过多的请求堆积,减少对系统资源的过度占用。

如何自定义一个 View?

自定义 View 是 Android 开发中非常重要的一个技能,它可以让我们创建出符合应用特定需求的用户界面元素。

首先,要创建一个自定义 View,需要创建一个继承自View或者它的子类(如TextView、ImageView等)的新类。例如,如果我们想要创建一个自定义的圆形视图,我们可以这样开始:

public class CircleView extends View {
    // 用于绘制的画笔
    private Paint paint;
    public CircleView(Context context) {
        super(context);
        init();
    }
    public CircleView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }
    public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }
    private void init() {
        paint = new Paint();
        paint.setColor(Color.RED);
        paint.setStyle(Paint.Style.FILL);
    }
}

在这个例子中,我们创建了一个CircleView类,它继承自View。在构造函数中,我们调用了init()方法来进行一些初始化操作,比如创建一个用于绘制的画笔Paint对象,并设置了画笔的颜色为红色,样式为填充。

接下来,我们需要重写onMeasure()方法。这个方法用于确定视图的大小。在onMeasure()方法中,我们需要考虑视图的布局参数和自身的内容来确定合适的宽和高。例如,对于我们的圆形视图,我们可能希望它是一个正方形,那么我们可以这样实现onMeasure()方法:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    int size;
    if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
        size = Math.min(widthSize, heightSize);
    } else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
        size = 200;
    } else {
        size = widthMode == MeasureSpec.EXACTLY? widthSize : heightSize;
    }
    setMeasuredDimension(size, size);
}

在这个方法中,我们首先获取了宽度和高度的测量模式和测量大小。然后根据不同的测量模式来确定视图的最终大小。如果宽度和高度都是精确指定的(MeasureSpec.EXACTLY),我们选择较小的值作为视图的大小,以确保它是一个正方形。如果宽度和高度都是MeasureSpec.AT_MOST模式,我们设置一个默认大小为 200 像素。最后,我们通过setMeasuredDimension()方法来设置视图的测量大小。

然后,我们需要重写onDraw()方法。这个方法是自定义视图的核心,用于绘制视图的内容。对于我们的圆形视图,我们可以这样实现onDraw()方法:

@Override
protected void onDraw(Canvas canvas) {
    int radius = getWidth() / 2;
    canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius, paint);
}

在这个方法中,我们首先计算了圆形的半径,它是视图宽度的一半。然后,我们使用canvas.drawCircle()方法来绘制一个圆形,圆心位于视图的中心,半径为刚才计算的值,使用我们之前初始化的画笔paint来进行绘制。

除了上述基本的方法,我们还可以为自定义视图添加自定义属性。我们可以在res/values目录下创建一个attrs.xml文件,在这个文件中定义我们的自定义属性。例如:

<?xml><resources>
    <declare - styleable name="CircleView">
        <attr name="circleColor" format="color" />
    </declare - styleable>
</resources>

在这个例子中,我们定义了一个名为circleColor的属性,它的类型是颜色。然后,我们需要在自定义视图的构造函数中获取并使用这个属性。例如:

public CircleView(Context context, AttributeSet attrs) {
    super(context, attrs);
    init();
    TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
    int color = a.getColor(R.styleable.CircleView_circleColor, Color.RED);
    paint.setColor(color);
    a.recycle();
}

在这个构造函数中,我们通过context.obtainStyledAttributes()方法获取了TypedArray对象,然后使用这个对象获取了circleColor属性的值。如果没有指定这个属性的值,我们使用默认的红色。最后,我们需要调用a.recycle()方法来回收TypedArray对象,以避免内存泄漏。

通过以上步骤,我们就成功地创建了一个自定义视图。在布局文件中,我们可以像使用其他视图一样使用这个自定义视图,并且可以通过自定义属性来定制它的外观。

View 的绘制流程是怎样的?

View 的绘制流程主要分为三个阶段:测量(measure)、布局(layout)和绘制(draw)。

在测量阶段,主要是确定 View 的大小。父 View 会调用子 View 的 measure 方法来开始这个过程。这个方法会传入两个参数,widthMeasureSpec 和 heightMeasureSpec,这两个参数是由父 View 的布局参数和自身的 MeasureSpec 模式组合而成。MeasureSpec 模式有三种:UNSPECIFIED(未指定,父容器不对子 View 的大小做限制)、EXACTLY(精确值,例如在布局文件中指定了具体的 dp 值或者 match_parent)、AT_MOST(最大值,例如 wrap_content)。子 View 在 measure 方法中会根据这两个参数和自身的内容来确定自己的测量宽高,并且会通过 setMeasuredDimension 方法来保存测量后的宽高值。

布局阶段是在测量完成之后进行的。父 View 会调用子 View 的 layout 方法来确定子 View 在父 View 中的位置。layout 方法传入四个参数,分别是 left、top、right、bottom,这些参数确定了子 View 在父 View 中的矩形区域。子 View 在这个阶段会根据这些参数来设置自己的位置,并且会调用 onLayout 方法。如果子 View 是一个容器类的 View,如 LinearLayout,它还会在 onLayout 方法中遍历所有的子 View,并调用它们的 layout 方法来确定它们的位置。

绘制阶段是最后一步。当布局完成后,View 会调用 draw 方法来进行绘制。draw 方法是一个比较复杂的过程,它会依次调用 onDraw 方法来绘制 View 的内容。在 onDraw 方法中,我们可以使用 Canvas 对象来进行绘制操作,例如绘制图形、文本等。在绘制之前,还会进行一些准备工作,比如会先绘制背景(如果有),然后保存图层(如果需要),在绘制完成后还会恢复图层并绘制装饰(如滚动条等)。整个绘制过程是按照一定的顺序来进行的,以确保 View 的内容能够正确地显示在屏幕上。

View 绘制屏幕刷新率和帧率控制是如何实现的?

在 Android 系统中,屏幕刷新率是指屏幕每秒更新的次数,帧率是指应用程序每秒绘制的帧数。

对于屏幕刷新率,这主要是由硬件决定的。不同的设备有不同的屏幕刷新率,例如常见的 60Hz、90Hz、120Hz 等。系统会按照屏幕刷新率来定期触发 VSync 信号(垂直同步信号)。这个信号是控制屏幕刷新的关键。

在应用层,帧率的控制和 View 的绘制紧密相关。Android 系统通过 Choreographer 类来协调帧率和屏幕刷新率之间的关系。Choreographer 会接收 VSync 信号,当收到信号后,它会开始安排下一帧的绘制工作。它会遍历所有需要更新的 View,从根 View 开始,按照前面提到的绘制流程(测量、布局、绘制)来更新 View 的内容。

为了控制帧率,Android 应用可以通过一些方法来优化绘制。例如,减少不必要的 View 重绘。当 View 的内容没有发生变化时,尽量避免调用 invalidate 方法来触发重绘。因为每次 invalidate 都会导致 View 重新进入绘制流程,这会消耗系统资源并且可能会影响帧率。

另外,在复杂的 UI 场景下,可以使用硬件加速来提高绘制效率。硬件加速会利用 GPU 来处理一些绘制任务,比如绘制图形、处理图像等。这样可以减轻 CPU 的负担,从而提高帧率。但是硬件加速也不是万能的,在某些情况下可能会导致兼容性问题或者增加内存消耗,所以需要根据具体情况来合理使用。

同时,在代码层面,我们可以通过一些工具来监测帧率。例如,Android 系统提供了一些性能分析工具,如 Systrace,它可以帮助我们分析应用的绘制性能,查看每一帧的绘制时间,从而发现可能存在的性能瓶颈,以便采取相应的措施来优化帧率。

事件是怎么从硬件层传递到应用层的?

事件从硬件层传递到应用层是一个比较复杂的过程。

首先,在硬件层,当用户触摸屏幕或者进行其他输入操作(如按键按下)时,硬件设备(如触摸屏控制器)会检测到这些操作,并将其转换为电信号。这些电信号会通过硬件驱动程序传递给内核。

在内核中,有一个输入子系统来处理这些事件。输入子系统会对硬件驱动传来的信号进行初步的处理和解析。例如,对于触摸事件,它会解析出触摸的位置、压力等信息,并将这些信息封装成一个统一的输入事件结构体。这个结构体包含了事件的类型(如触摸事件、按键事件等)、事件的详细参数(如触摸坐标、按键码等)。

然后,内核会通过 Linux 的输入设备接口(如 /dev/input/eventX)将这些输入事件传递给 Android 系统的运行时环境。在 Android 系统中,有一个名为 InputManagerService(IMS)的系统服务来接收这些事件。IMS 是整个 Android 输入系统的核心服务。

IMS 会对输入事件进行进一步的处理和分发。它会根据事件的类型和当前的系统状态来决定将事件发送到哪个应用程序。例如,如果当前有一个应用处于前台,并且该应用的窗口包含了触摸点的位置,那么 IMS 会将触摸事件发送到这个应用。在发送事件之前,IMS 还会对事件进行一些预处理,如过滤一些无效的事件、调整事件的顺序等。

当事件到达应用层后,会首先传递给 Activity。Activity 会通过 Window 对象将事件传递给 View 树。View 树会从根 View 开始,按照一定的规则来分发事件,这个规则主要是根据 View 的布局位置和事件的坐标来确定哪个 View 应该接收和处理这个事件。最终,事件会被传递到对应的 View,由 View 来处理这个事件,例如响应用户的触摸操作,改变自身的状态或者执行相应的逻辑。

DOWN 事件有几个坐标点,MOVE 事件呢?

对于 DOWN 事件,它通常只有一个坐标点。这个坐标点代表了用户手指首次接触屏幕的位置。这个位置信息包含了屏幕上的横坐标和纵坐标,通常以像素为单位。在 Android 系统中,这个坐标是相对于当前接收事件的 View 的坐标系而言的。当用户的手指触摸屏幕时,系统会获取这个触摸点的位置,并将其作为 DOWN 事件的坐标点,这个坐标点对于后续的事件处理非常重要,例如判断用户的触摸手势方向(如向左滑动、向右滑动等),以及确定用户是否在某个特定的 View 区域内进行触摸操作。

MOVE 事件则可以有多个坐标点。MOVE 事件是在用户手指在屏幕上移动时产生的。每次手指移动到一个新的位置,系统都会产生一个新的 MOVE 事件,并包含当前手指所在位置的坐标点。这些坐标点形成了一个移动轨迹。应用程序可以通过获取这些 MOVE 事件的坐标点来实现各种交互功能,比如跟随手指移动的绘图应用,它会根据 MOVE 事件的坐标点来绘制线条,每一个 MOVE 事件的坐标点都会作为线条的一个新的端点,从而实现连续的绘图效果。同时,通过分析 MOVE 事件坐标点的变化情况,还可以实现一些复杂的手势识别,比如判断用户是在进行直线滑动还是曲线滑动,或者是在进行缩放操作(通过计算两个手指的 MOVE 事件坐标点之间的距离变化)。

事件分发详细## 的## 讲(三个核心函数以及整体过程),详细## 的## 讲下 dispatchTouchEvent,子 View 如何阻止父 View 拦截事件,Action_Cancel 什么时候会发生?

在 Android 的事件分发机制中,有三个核心函数:dispatchTouchEvent、onInterceptTouchEvent 和 onTouchEvent。

dispatchTouchEvent 是事件分发的入口函数。这个函数存在于 View 和 ViewGroup 中。对于 ViewGroup 来说,当有触摸事件传入时,dispatchTouchEvent 函数首先会被调用。它的主要作用是将事件分发给子 View 或者自己处理。在 dispatchTouchEvent 函数中,ViewGroup 会首先调用 onInterceptTouchEvent 函数来判断是否要拦截这个事件。如果 onInterceptTouchEvent 返回 true,那么事件就不会再传递给子 View,而是由 ViewGroup 自己的 onTouchEvent 函数来处理。如果 onInterceptTouchEvent 返回 false,那么 ViewGroup 会遍历它的子 View,通过调用子 View 的 dispatchTouchEvent 函数来将事件传递给子 View。

onInterceptTouchEvent 函数主要存在于 ViewGroup 中。它的作用是判断 ViewGroup 是否要拦截事件。这个函数可以根据事件的类型、当前 ViewGroup 和子 View 的状态等因素来决定是否拦截。例如,如果 ViewGroup 实现了一个滑动效果,当用户的触摸手势符合滑动条件时,它可能会拦截事件,自己来处理滑动逻辑。

onTouchEvent 函数存在于 View 和 ViewGroup 中。当事件没有被拦截并且传递到 View 时,或者 ViewGroup 决定自己处理事件时,onTouchEvent 函数会被调用。这个函数主要负责处理实际的触摸事件。例如,对于一个按钮 View,onTouchEvent 函数会处理用户的点击操作,改变按钮的状态并且触发点击事件的监听器。

整体的事件分发过程是这样的:当有触摸事件发生时,事件首先会传递到 Activity,Activity 会将事件传递给它的根 View(通常是一个 ViewGroup)。根 ViewGroup 的 dispatchTouchEvent 函数会被调用,然后按照上述的规则来判断是否拦截事件。如果不拦截,会将事件传递给子 View,子 View 再重复这个过程,直到事件被处理或者到达叶子 View(没有子 View 的 View)。

对于 dispatchTouchEvent 函数,它的返回值决定了事件的流向。如果返回 true,那么表示这个 View 或者 ViewGroup 已经处理了这个事件,事件不会再向上或者向下传递。如果返回 false,那么事件会按照相反的方向传递。例如,如果子 View 的 dispatchTouchEvent 返回 false,那么事件会传递给父 View 的 onTouchEvent 函数来处理。

子 View 可以通过调用 getParent ().requestDisallowInterceptTouchEvent (true) 来阻止父 View 拦截事件。当子 View 希望自己完全处理某个事件序列(例如从 DOWN 事件开始的一系列 MOVE 和 UP 事件)时,可以使用这个方法。这样父 View 的 onInterceptTouchEvent 函数就会被忽略,事件会一直传递给子 View,直到子 View 处理完这个事件序列或者自己调用 getParent ().requestDisallowInterceptTouchEvent (false) 来允许父 View 拦截。

Action_Cancel 事件通常会在以下情况下发生。一种情况是当父 View 拦截了已经传递给子 View 的事件时。例如,子 View 已经开始处理一个触摸事件序列(如滑动操作),但是父 View 在中途决定拦截这个事件,此时子 View 会收到一个 Action_Cancel 事件,来通知它停止处理这个事件。另一种情况是当系统发生一些特殊情况,比如窗口焦点发生变化或者视图状态发生改变(如 View 被隐藏或者删除),正在处理事件的 View 也可能会收到 Action_Cancel 事件。

Activity 的生命周期是怎样的?

Activity 的生命周期包含多个阶段和对应的回调方法。

首先是 onCreate 方法。这个方法在 Activity 被创建时调用。通常在这里进行一些初始化的操作,比如设置布局、初始化变量、绑定数据等。例如,我们会使用 setContentView 方法来加载布局文件,创建视图对象,并且可以在这里获取布局文件中的视图,通过 findViewById 方法来进行初始化操作。同时,也可以在这里初始化一些数据相关的操作,如创建数据库连接、加载初始数据等。

接着是 onStart 方法。这个方法在 Activity 变得可见时调用,但此时 Activity 可能还没有出现在前台。例如,当一个 Activity 从后台恢复到前台的过程中,它会先经过 onStart 阶段。在这个阶段,Activity 已经可以被用户看到一部分,但是可能还没有获取到焦点,不能与用户进行交互。

onResume 方法是在 Activity 开始与用户进行交互时调用。此时 Activity 已经完全可见并且获取到了焦点。这是 Activity 生命周期中非常重要的一个阶段,因为在这个阶段,用户可以与 Activity 进行各种交互操作,如点击按钮、输入文字等。在这个阶段,通常会启动一些需要与用户交互的操作,比如启动动画、开启传感器监听等。

当 Activity 失去焦点但仍然可见时,会调用 onPause 方法。例如,当一个透明的或者半透明的 Activity 覆盖在当前 Activity 之上时,当前 Activity 会进入 onPause 阶段。在这个阶段,应该暂停一些不必要的操作,如停止动画、暂停传感器监听等,以节省系统资源。但是要注意,这个阶段的操作应该尽量快速完成,因为如果 onPause 方法执行时间过长,会影响用户体验,并且可能会导致系统认为 Activity 出现问题。

onStop 方法是在 Activity 完全不可见时调用。例如,当 Activity 被另一个 Activity 完全覆盖或者用户按下返回键退出 Activity 时,会进入 onStop 阶段。在这个阶段,可以释放一些资源,如关闭数据库连接、停止网络请求等,因为 Activity 可能在很长一段时间内不会再被使用。

如果 Activity 被系统销毁,会调用 onDestroy 方法。这个方法用于清理 Activity 占用的资源,如释放内存、注销广播接收器等。Activity 可能会因为多种原因被销毁,如系统内存不足、用户手动关闭 Activity 或者配置发生变化(如屏幕旋转)等。

另外,还有一些特殊的生命周期回调方法。例如,当 Activity 因为配置变化(如屏幕旋转)而重新创建时,会先调用 onSaveInstanceState 方法。这个方法用于保存 Activity 的当前状态,如视图的状态、变量的值等。在 Activity 重新创建后,可以通过 onRestoreInstanceState 方法来恢复之前保存的状态。

Activity 启动模式有哪些?SingleTop 和 standard 启动模式下,生命周期回调有何不同?

Android 中 Activity 的启动模式主要有四种:standard(标准模式)、singleTop(栈顶复用模式)、singleTask(栈内复用模式)和 singleInstance(单实例模式)。

standard 模式是默认的启动模式。在这种模式下,每当启动一个 Activity,就会创建一个新的该 Activity 实例并放入任务栈中。例如,假设有 Activity A 使用 standard 模式,当多次启动 Activity A 时,每次都会创建一个新的 A 实例,其生命周期会完整地从 onCreate 开始,依次经过 onStart、onResume 等阶段。

singleTop 模式下,如果要启动的 Activity 已经处于任务栈的栈顶,那么系统不会创建新的实例,而是会调用该 Activity 的 onNewIntent 方法。如果不在栈顶,就会像 standard 模式一样创建新的实例。例如,有 Activity B 采用 singleTop 模式,当 B 处于栈顶,再次启动 B,它不会重新走 onCreate 等方法,只会执行 onNewIntent,这样可以避免重复创建相同的栈顶 Activity。但如果 B 不在栈顶,如栈中有 A - B - C 这样的顺序,从 C 启动 B,就会重新创建 B 的实例,生命周期从 onCreate 开始。

在 standard 模式下,每次启动 Activity 都是全新的生命周期流程,会完整地创建和初始化 Activity。而 singleTop 模式在栈顶复用的情况下,跳过了创建新实例的过程,通过 onNewIntent 方法来处理新的意图,减少了资源的浪费和不必要的初始化操作。

onStart 和 onResume 的区别是什么?

onStart 和 onResume 都是 Activity 生命周期中的重要回调方法,但它们有着明显的区别。

onStart 方法是在 Activity 变得可见时被调用。这个阶段意味着 Activity 已经在屏幕上部分或者全部可见,但还不一定能和用户进行交互。例如,当一个 Activity 从后台切换到前台的过程中,会先调用 onStart 方法。此时,Activity 可能还没有获取到焦点,比如当一个透明或者半透明的 Activity 覆盖在它上面时,它已经可见,但用户还不能直接操作它。在这个阶段,系统会进行一些与显示相关的操作,如布局的加载和视图的绘制,但交互相关的操作(如点击事件的处理)还没有完全准备好。

onResume 方法则是在 Activity 获取到焦点并开始和用户进行交互时调用。这是 Activity 完全处于前台并且可以接收用户输入的阶段。例如,当用户在屏幕上进行触摸操作、按键操作等交互行为时,此时 Activity 必须处于 onResume 状态。在这个阶段,通常会启动一些与用户交互密切相关的操作,比如开启传感器监听、动画的播放等。

从系统资源的角度看,onStart 阶段主要关注 Activity 的可见性相关的资源准备,如视图的显示;而 onResume 阶段更侧重于和用户交互相关资源的启用。并且在 Activity 的状态转换过程中,onStart 会在 Activity 从不可见到可见的过渡阶段调用,而 onResume 是在可见且可交互这个最终状态下调用。例如,当 Activity 从后台恢复到前台,先调用 onStart 让其可见,然后调用 onResume 使其可交互。

多线程通信的方式有哪些?

在 Android 开发中,多线程通信有多种方式。

一种常见的方式是通过 Handler 和 Message。Handler 主要用于在不同线程之间传递消息。在主线程中创建一个 Handler 对象,它会关联到主线程的消息队列。在子线程中,可以通过这个 Handler 发送消息。消息(Message)是一个包含了一些数据和指令的对象,比如可以在消息中携带需要在主线程处理的数据。当子线程发送消息后,主线程的 Handler 会从消息队列中获取这个消息,并在 handleMessage 方法中处理。例如,在一个网络请求的子线程中,当获取到网络数据后,可以将数据封装成一个消息,通过 Handler 发送到主线程,然后在主线程更新 UI。

另一种方式是使用 AsyncTask。AsyncTask 是一个抽象类,它简化了在 Android 中进行异步任务和 UI 更新的操作。它内部使用了线程池和 Handler 机制。AsyncTask 有几个重要的方法,如 doInBackground 方法在后台线程执行任务,如网络请求、文件读取等。在这个方法中可以进行耗时操作。当任务完成后,可以通过 publishProgress 方法将中间结果发送到主线程,在 onProgressUpdate 方法中更新 UI。当整个任务完成后,会在 onPostBackground 方法中更新最终的 UI。

还可以通过共享变量和锁机制来进行多线程通信。例如,定义一个全局的变量,在多个线程中访问和修改这个变量。但是这种方式需要注意线程安全问题。为了保证数据的正确性,需要使用锁(如 synchronized 关键字)来控制对共享变量的访问。例如,在一个读写共享变量的场景中,当一个线程在写变量时,通过锁机制来阻止其他线程同时进行写操作或者读取不一致的数据。

此外,在 Java 1.5 之后引入了 BlockingQueue 这种阻塞队列来进行多线程通信。在生产者 - 消费者模型中非常有用。生产者线程将数据放入阻塞队列,消费者线程从阻塞队列中取出数据进行处理。如果队列已满,生产者线程会被阻塞;如果队列已空,消费者线程会被阻塞,这样可以很好地协调多个线程之间的工作。

为什么要有线程池?

线程池的存在主要是为了更高效地管理和复用线程,以提高系统的性能和资源利用率。

从线程复用的角度来看,创建和销毁线程是有一定成本的。当创建一个新线程时,系统需要为这个线程分配内存空间,包括栈空间等。同时,还需要进行一些初始化操作,如加载线程上下文等。当线程结束后,系统又要回收这些资源。如果频繁地创建和销毁线程,尤其是在处理大量短期任务时,这些创建和销毁的开销会变得很大。

线程池通过预先创建一定数量的线程并维护这些线程在一个池中。当有任务到来时,不是创建新的线程,而是从线程池中获取一个空闲的线程来执行任务。例如,一个简单的线程池中有 5 个线程,当有 6 个任务到来时,前 5 个任务会立即分配到 5 个线程中执行,第 6 个任务会等待其中一个线程完成任务后变为空闲,再由这个空闲线程来执行。

线程池还可以根据任务的数量和系统的负载动态地调整线程的数量。有些线程池实现允许设置核心线程数和最大线程数。核心线程数是线程池中始终保持的线程数量,即使这些线程处于空闲状态。当任务数量超过核心线程数时,线程池会根据一定的规则创建新的线程,直到达到最大线程数。当任务减少时,超过核心线程数的线程会在一段时间后被回收。

这样,通过线程复用,减少了线程创建和销毁的次数,降低了系统开销。同时,线程池还可以对线程进行统一的管理,如设置线程的优先级、控制线程的执行时间等,使得多线程应用更加高效和稳定。

发线程数量越多性能越好吗?

并不是线程数量越多性能就越好。当线程数量过多时,会导致线程切换频繁,从而影响系统性能。

在操作系统中,线程是由 CPU 进行调度的。CPU 会在不同的线程之间进行切换,以保证每个线程都能得到执行的机会。当线程数量增加时,CPU 需要花费更多的时间在不同线程之间进行切换。每次线程切换,CPU 都需要保存当前线程的执行上下文,包括程序计数器、寄存器的值等,然后加载下一个线程的执行上下文。这个过程是有一定开销的。

例如,假设有一个单核 CPU 系统,同时运行 10 个线程。CPU 需要在这 10 个线程之间频繁切换。如果每个线程的执行时间很短,那么 CPU 可能大部分时间都在进行线程切换操作,而真正用于执行线程任务的时间就会减少。而且,频繁的线程切换还可能导致缓存失效。因为每个线程可能会访问不同的数据,当线程切换时,CPU 缓存中的数据可能就不再是当前线程所需要的数据,需要重新从内存中加载,这也会增加系统的延迟。

另外,过多的线程还可能导致资源竞争加剧。多个线程可能会同时访问共享资源,如内存、文件、网络连接等。这就需要通过一些同步机制(如锁)来保证数据的正确性。但是这些同步机制也会带来额外的开销,如线程的阻塞和唤醒,进一步降低系统性能。所以,在设计多线程应用时,需要根据任务的性质、系统的资源(如 CPU 核心数)等来合理地确定线程的数量,而不是盲目地增加线程数量来追求所谓的高性能。

如何设计一个线程池?(结合原理讲)

设计一个线程池主要考虑几个关键因素。首先是核心线程数,这是线程池中始终保持存活的线程数量。它的确定需要考虑任务的类型和系统资源。如果是 CPU 密集型任务,核心线程数一般设置为 CPU 核心数加 1 或者等于 CPU 核心数,因为 CPU 密集型任务主要消耗 CPU 资源,过多的线程会导致线程切换频繁而降低效率。对于 IO 密集型任务,由于线程在等待 IO 操作时会处于阻塞状态,所以可以设置较多的核心线程数,比如可以根据 IO 设备的性能和任务等待 IO 的时间来估算,通常可以设置为 CPU 核心数的两倍或者更多。

线程池还需要考虑最大线程数。当任务队列已满并且核心线程数无法满足任务需求时,线程池会创建新的线程,直到达到最大线程数。最大线程数的设置要避免无限制地创建线程导致系统资源耗尽。它通常要考虑系统的内存、CPU 等资源限制以及任务的并发量上限。

任务队列是线程池的重要组成部分。它用于存储等待执行的任务。常见的任务队列有阻塞队列,如 ArrayBlockingQueue、LinkedBlockingQueue 等。阻塞队列可以在队列为空时让获取任务的线程阻塞,在队列已满时让添加任务的线程阻塞,这样可以有效地协调任务的生产者和消费者(线程)之间的关系。

另外,线程池还需要有线程的管理机制。包括线程的创建、销毁和空闲线程的回收。当线程完成任务后,如果超过核心线程数的线程空闲时间达到一定阈值,就可以将其回收。同时,当有新任务到来并且线程池没有足够的线程时,需要按照一定的策略创建新的线程。

还有线程池的拒绝策略也很重要。当任务队列已满并且达到最大线程数后,新的任务到来时就需要有拒绝策略。常见的拒绝策略有直接抛出异常、丢弃新任务、丢弃任务队列头部的任务来腾出空间给新任务或者由调用者线程来执行新任务等。通过合理地设置这些参数和机制,就可以设计出一个适合具体应用场景的高效线程池。

线程池有 CPU 密集型和 IO 密集型线程池,他们的区别是什么?

CPU 密集型线程池和 IO 密集型线程池主要在任务特性、线程数量配置、执行效率重点等方面存在区别。

从任务特性来看,CPU 密集型线程池主要用于处理那些需要大量 CPU 计算的任务。例如,复杂的数学计算、图像渲染等任务。这些任务大部分时间都在占用 CPU 资源进行计算,很少会因为等待外部设备(如磁盘、网络等)而处于空闲状态。而 IO 密集型线程池主要用于处理那些频繁进行输入输出操作的任务。比如网络请求、文件读取和写入等任务。这些任务的特点是会经常处于等待 IO 操作完成的状态,在等待期间线程是空闲的。

在线程数量配置方面,对于 CPU 密集型线程池,由于任务主要依赖 CPU 资源,线程数量一般设置为和 CPU 核心数相当或者稍微多一点。这是因为如果线程数量过多,会导致 CPU 频繁地在多个线程之间切换,反而降低了整体的执行效率。例如,在一个四核 CPU 的系统中,CPU 密集型线程池的核心线程数可以设置为 4 或者 5。对于 IO 密集型线程池,由于线程在等待 IO 操作时会空闲,所以可以设置较多的线程数量。通常可以设置为 CPU 核心数的两倍或者更多,这样可以充分利用线程等待 IO 的时间来执行其他任务,提高系统的整体利用率。

在执行效率重点上,CPU 密集型线程池重点在于减少线程切换,让 CPU 能够持续地处理计算任务。所以在设计和使用这种线程池时,要尽量保证每个线程有足够的 CPU 时间片来完成计算任务。而 IO 密集型线程池重点在于如何高效地利用线程等待 IO 的空闲时间。例如,通过合理的任务调度,让多个线程可以交替进行 IO 操作和其他计算任务,从而减少整体的任务等待时间。

线程并发相关,悲观锁,乐观锁的区别?

悲观锁和乐观锁是在处理多线程并发访问共享资源时采用的不同策略。

悲观锁的核心思想是假设在数据被访问的时候,很可能会被其他线程修改,所以在操作数据之前就先对数据进行加锁。例如,当一个线程要修改一个共享变量时,它会先获取这个变量对应的锁。在这个线程持有锁的期间,其他线程如果想要访问这个变量,就必须等待这个线程释放锁。就好像一个很谨慎的人,总是担心别人会来打扰自己正在做的事情,所以先把资源 “保护” 起来。常见的悲观锁实现有 synchronized 关键字(在 Java 中),它可以用于修饰方法或者代码块。当一个方法被 synchronized 修饰时,同一时刻只有一个线程能够访问这个方法所操作的资源。还有像 ReentrantLock 也是一种悲观锁,它提供了更灵活的锁获取和释放机制,比如可以设置公平锁或者非公平锁。

乐观锁则是一种相对乐观的策略。它假设在数据被访问的时候,其他线程很少会修改数据。所以不会像悲观锁那样在操作之前就加锁。而是在更新数据的时候,会先检查数据是否被其他线程修改过。如果没有被修改,就进行更新操作;如果被修改了,就根据具体的策略来处理,比如重试操作或者抛出异常。乐观锁通常使用版本号或者时间戳来实现。例如,在数据库中有一个表的记录,有一个 version 字段作为版本号。当一个线程要更新这条记录时,它会先读取记录的版本号,在更新时会检查版本号是否和之前读取的一致,如果一致就更新,并且更新后的版本号加 1。如果不一致,就说明有其他线程已经更新了这条记录,这个线程就需要重新考虑操作。

悲观锁的优点是安全性高,能够保证在同一时刻只有一个线程对共享资源进行修改,适用于对数据一致性要求很高的场景。但是它的缺点是会导致线程的阻塞和等待,降低系统的并发性能。乐观锁的优点是不会阻塞线程,能够提高系统的并发度,但是它需要处理更新冲突的情况,而且如果冲突频繁发生,会导致重试次数过多,增加系统的开销。

Synchonized 和 AtomicInteger 的区别?

synchronized 是 Java 中的一个关键字,用于实现互斥同步,它可以修饰方法或者代码块。当一个方法被 synchronized 修饰时,在同一时刻只有一个线程能够访问这个方法。如果是修饰代码块,那么在这个代码块执行期间,只有持有锁的线程能够进入这个代码块。它的工作原理是基于对象头中的锁标志位来实现锁的获取和释放。当一个线程访问被 synchronized 修饰的方法或者代码块时,会先尝试获取对象的锁,如果锁已经被其他线程持有,那么这个线程就会进入阻塞状态,直到获取到锁。

AtomicInteger 是 Java 中的一个原子类,它提供了原子性的操作。原子性是指一个操作在执行过程中不会被其他线程中断。例如,AtomicInteger 的 getAndIncrement 方法,它会以原子的方式将当前的值加 1 并返回旧值。它的实现原理是基于硬件的原子指令(如 CAS - 比较并交换)。CAS 操作包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。当且仅当预期原值 A 和内存位置 V 的值相匹配时,才会将内存位置 V 的值更新为新值 B。这种方式避免了使用传统的锁机制,减少了线程的阻塞和唤醒,从而提高了并发性能。

synchronized 的使用范围比较广泛,可以用于保护任何代码块或者方法。但是它会导致线程的阻塞,可能会降低系统的并发性能。尤其是在高并发场景下,如果锁的竞争很激烈,会导致很多线程处于等待状态。AtomicInteger 主要用于对单个变量进行原子操作,它更专注于提供高效的原子操作,适合在高并发环境下对简单变量(如整数)进行操作。而且 AtomicInteger 不需要像 synchronized 那样显式地获取和释放锁,使用起来更加简洁。

协程调度器是如何工作的?

协程调度器主要负责协程的调度和执行。

协程是一种轻量级的线程,它和普通线程不同,不是由操作系统内核来调度,而是由协程调度器来调度。协程调度器会维护一个协程队列,里面包含了等待执行和正在执行的协程。

当一个协程被创建后,它会被放入协程队列中。协程调度器会根据一定的策略来决定哪个协程应该被执行。这个策略可以是基于优先级、先来后到或者其他自定义的规则。例如,在一个简单的协程调度器中,按照协程创建的顺序来执行,先创建的协程先执行。

在协程执行过程中,协程可以主动暂停自己的执行。这是协程的一个重要特性。当协程遇到一些需要等待的情况,比如等待 IO 操作完成或者等待一个延迟时间结束,它不会像普通线程那样阻塞整个线程,而是将执行权交给协程调度器。协程调度器会从协程队列中选择另一个协程来执行。

当协程等待的条件满足后,例如 IO 操作完成或者延迟时间到了,协程会重新被协程调度器放入可执行的协程队列中,等待下一次被调度执行。

协程调度器还可以根据系统的资源情况来调整协程的执行。例如,在资源紧张的时候,它可以限制同时执行的协程数量,或者调整协程的执行优先级,以保证系统的整体性能。同时,协程调度器也可以和不同的执行环境(如单线程或者多线程环境)相结合,在单线程环境下,通过协程的切换来模拟多任务的并发执行;在多线程环境下,可以将协程分配到不同的线程中执行,进一步提高系统的并发能力。

Java 中的值传递和引用传递有什么区别?

在 Java 中,值传递和引用传递是方法参数传递的两种方式,它们有明显的区别。

值传递是指在方法调用时,将实际参数的值复制一份传递给方法中的形式参数。这意味着,在方法内部对形式参数的修改不会影响到实际参数。例如,当传递一个基本数据类型(如 int、double 等)时,就是值传递。假设我们有一个方法changeValue,它接受一个int类型的参数:

public void changeValue(int num) {
    num = 10;
}

当我们在其他地方调用这个方法时,如int a = 5; changeValue(a);,a的值仍然是 5,因为在方法内部修改的num只是a的一个副本。

引用传递则是在方法调用时,将实际参数的引用(也就是对象的内存地址)传递给方法中的形式参数。这样,方法内部对形式参数的修改会影响到实际参数。在 Java 中,当我们传递对象时,实际上是引用传递。例如,我们有一个类Person:

class Person {
    String name;
    public Person(String name) {
        this.name = name;
    }
}

然后有一个方法changeName:

public void changeName(Person p) {
    p.name = "New Name";
}

当我们创建一个Person对象并调用这个方法时,如Person person = new Person("Old Name"); changeName(person);,person对象的name属性会被修改为New Name。这是因为在方法内部,p和person引用的是同一个对象。

需要注意的是,虽然 Java 中对象的传递看起来像是引用传递,但实际上是值传递。只不过这个值是对象的引用。这意味着我们不能在方法中重新分配形式参数(引用)来指向一个新的对象,而不影响实际参数。例如,如果在方法中执行p = new Person("Another Name");,这不会改变实际参数person所指向的对象。

自动装拆箱会遇到什么问题?

Java 中的自动装箱和拆箱是 Java 5.0 引入的一个方便的特性,但也可能会带来一些问题。

自动装箱是指 Java 自动将基本数据类型转换为包装类型。例如,将int转换为Integer。自动拆箱则是相反的过程,将包装类型转换为基本数据类型。

一个常见的问题是性能问题。因为装箱和拆箱操作会涉及到对象的创建和销毁。当在循环中频繁地进行装箱或拆箱操作时,会产生大量的临时对象,这会增加内存的开销并且影响性能。例如,考虑以下代码:

for (int i = 0; i < 10000; i++) {
    Integer num = i;
    // 一些操作
}

在每次循环中,i被自动装箱为Integer,这会创建 10000 个Integer对象。同样,在拆箱时也会有性能损耗。

另一个问题是可能会导致空指针异常。在自动拆箱过程中,如果包装类型的值为null,就会抛出空指针异常。例如:

Integer num = null;
int value = num;

在这个例子中,当试图对null的Integer进行拆箱时,就会抛出空指针异常。

还有一个容易被忽视的问题是在比较操作中的错误。对于基本数据类型,比较操作是比较它们的值。但对于包装类型,==操作符比较的是对象的引用,而不是值。例如:

Integer num1 = 100;
Integer num2 = 100;
System.out.println(num1 == num2);

在某些情况下,这个结果可能是true,但在另一些情况下可能是false。这是因为 Java 对于较小的Integer值(通常在 - 128 到 127 之间)会缓存对象,所以num1和num2可能引用的是同一个缓存对象。但对于超出这个范围的值,==操作会返回false,因为它们是不同的对象引用。如果想要比较Integer的值,应该使用equals方法。

了解的加密机制有哪些?

加密机制主要用于保护数据的安全性和隐私性,常见的有以下几种。

哈希加密是一种单向加密机制。它通过哈希函数将任意长度的数据转换为固定长度的哈希值。常见的哈希函数有 MD5、SHA - 1、SHA - 256 等。哈希加密的特点是不可逆,即无法从哈希值反推出原始数据。例如,在用户密码存储场景中,通常会将用户密码进行哈希加密后存储在数据库中。当用户登录时,输入的密码会再次进行哈希加密,然后与数据库中的哈希值进行比较。如果两个哈希值相同,就认为密码正确。但是,由于哈希函数的不可逆性,即使数据库中的哈希值被泄露,攻击者也很难获取到原始密码。不过,随着技术的发展,一些简单的哈希函数(如 MD5)因为存在安全漏洞(如碰撞问题,即不同的数据可能产生相同的哈希值),逐渐被更安全的哈希函数替代。

对称加密是一种加密和解密使用相同密钥的加密方式。常见的对称加密算法有 DES、3DES、AES 等。在对称加密中,发送方和接收方需要事先共享一个密钥。发送方使用这个密钥对数据进行加密,接收方使用相同的密钥对加密后的数据进行解密。例如,在企业内部的文件传输中,如果需要保证文件内容的机密性,可以使用对称加密。发送方使用密钥对文件进行加密后发送,接收方收到加密文件后,使用相同的密钥进行解密,从而获取文件的原始内容。对称加密的优点是加密和解密速度快,适用于对大量数据进行加密的场景。但它的缺点是密钥的管理和分发比较困难,因为密钥需要在发送方和接收方之间安全地传递,一旦密钥泄露,数据的安全性就无法保证。

非对称加密则是加密和解密使用不同密钥的加密方式。它有一对密钥,分别是公钥和私钥。公钥可以公开,任何人都可以获取;私钥则由持有者保密。例如,在数字签名和加密通信场景中,发送方可以使用接收方的公钥对数据进行加密,接收方使用自己的私钥进行解密。这样,即使公钥被其他人获取,也无法解密数据。同时,私钥持有者可以使用私钥对数据进行签名,其他人可以使用公钥来验证签名的真实性。常见的非对称加密算法有 RSA、ECC 等。非对称加密的优点是安全性高,特别是在密钥管理方面比对称加密更有优势。但它的缺点是加密和解密速度相对较慢,所以通常不用于对大量数据进行加密,而是用于密钥交换或者数字签名等场景。

什么是非对称加密,什么是对称加密?

对称加密是一种传统的加密方式。在这种加密方式中,加密和解密使用相同的密钥。

假设我们有一个密钥 K,对于要加密的明文数据 M,通过加密算法 E 使用密钥 K 进行加密,得到密文 C,即 C = E(K,M)。在解密时,使用相同的密钥 K 和解密算法 D 来恢复明文,即 M = D(K,C)。例如,使用 AES 算法进行对称加密,发送方和接收方事先共享一个 128 位、192 位或者 256 位的密钥。发送方将数据通过 AES 加密算法和这个密钥转换为密文发送给接收方,接收方收到密文后,使用相同的密钥和 AES 解密算法将密文还原为原始数据。

对称加密的优点是加密和解密的速度相对较快。因为使用相同的密钥,算法的实现相对简单,所以在处理大量数据时,能够快速地完成加密和解密操作。它适用于对数据量较大且对实时性要求较高的场景,如本地文件加密或者内部网络中的数据传输加密。

然而,对称加密的缺点也很明显。主要是密钥管理的问题。由于加密和解密使用相同的密钥,这个密钥必须在发送方和接收方之间安全地传递。如果密钥在传递过程中被泄露,那么整个加密系统就会失效。而且,在一个包含多个用户的系统中,如果每个用户之间都需要进行加密通信,那么就需要为每对用户分配一个独立的密钥,随着用户数量的增加,密钥的管理会变得非常复杂。

非对称加密则采用了不同的方式。它使用一对密钥,分别是公钥和私钥。公钥可以公开,任何人都可以获取;私钥则由特定的用户或者系统保密。

对于加密过程,发送方使用接收方的公钥对数据进行加密。假设接收方的公钥为 PK,明文数据为 M,加密后的密文为 C,通过加密算法 E,有 C = E(PK,M)。在解密时,只有拥有私钥 SK 的接收方能够解密,即 M = D(SK,C),其中 D 是解密算法。例如,在 RSA 非对称加密算法中,公钥用于加密,私钥用于解密。

非对称加密的优点是安全性高,特别是在密钥管理方面。因为公钥可以公开,所以在密钥分发过程中不存在像对称加密那样的安全风险。它适用于在不安全的网络环境中进行安全通信,如在互联网上进行的电子商务交易、数字签名等场景。

不过,非对称加密的缺点是加密和解密的速度相对较慢。由于算法的复杂性,它在处理大量数据时效率较低。所以在实际应用中,通常会将对称加密和非对称加密结合使用。例如,先使用非对称加密来传递对称加密的密钥,然后使用对称加密来处理大量的数据。

loop 方法为何不会造成 ANR?

在 Android 中,Looper 的 loop 方法不会轻易导致 ANR(应用无响应)是有原因的。

首先,ANR 主要是因为主线程长时间阻塞在某个操作上,导致无法及时响应系统事件,如用户输入或者系统广播等。而 Looper 的 loop 方法虽然是一个循环,但它并不是一个阻塞操作。在 loop 方法内部,它会不断地从消息队列(MessageQueue)中获取消息。如果消息队列中没有消息,Looper 会进入等待状态,让出 CPU 资源。这个等待是通过系统底层的机制实现的,例如使用 Linux 的 epoll 等机制,它可以高效地等待新消息的到来,而不会一直占用 CPU。

当有新消息进入消息队列时,系统会唤醒 Looper,然后 Looper 会快速地获取并处理这个消息。并且,在处理消息的过程中,只要每个消息的处理时间是合理的,就不会导致 ANR。例如,在主线程的消息队列中,大部分消息是用于处理用户交互或者更新 UI 的简短操作,像响应用户的点击事件、更新一个文本视图的内容等,这些操作通常能够在短时间内完成。

另外,Android 系统本身对于一些可能导致 ANR 的情况也有一定的限制和监控。如果一个消息的处理时间过长,系统会发出警告,并且如果超过一定的时间阈值(如 5 秒,在某些情况下),就会判定为 ANR。但正常情况下,只要按照 Android 的开发规范,合理地使用 Handler 和 Looper 机制,通过消息队列来分发任务,避免在主线程进行耗时的操作,如网络请求、复杂的文件读取等,loop 方法就不会造成 ANR。

是否能在主线程更新 UI?

在 Android 中是可以在主线程更新 UI 的,并且通常建议在主线程更新 UI。

Android 的 UI 操作不是线程安全的,这是因为 UI 组件的内部实现涉及到许多复杂的状态和资源管理。如果在多个线程同时操作 UI,很容易导致不可预测的错误,如视图状态的混乱、数据不一致等。所以,Android 系统规定只有创建 UI 组件的线程(也就是主线程)才能更新 UI 组件。

主线程中有一个消息循环(Looper)和消息队列(MessageQueue),通过 Handler 机制,可以将 UI 更新的任务放入消息队列中。例如,当在子线程中获取到数据后,想要更新 UI,可以创建一个 Handler 对象(如果在主线程中已经创建了 Handler,也可以使用已有的 Handler),然后将更新 UI 的任务封装成一个消息(Message)或者一个 Runnable 对象,通过 Handler 的 sendMessage 或者 post 方法发送到主线程的消息队列中。当消息被取出并执行时,就可以在主线程中安全地更新 UI。

在主线程更新 UI 的好处是可以保证 UI 操作的顺序和正确性。因为主线程的消息队列是按照一定顺序处理消息的,所以 UI 更新会按照发送消息的顺序依次进行。而且,由于 Android 系统本身对主线程的一些特殊处理,如系统事件的分发也是在主线程进行的,所以在主线程更新 UI 可以更好地与系统事件交互,提供流畅的用户体验。不过,要注意避免在主线程进行耗时的操作,否则会导致 UI 卡顿甚至 ANR,因为主线程在执行耗时操作时无法及时处理其他消息,包括用户输入等重要消息。

同步屏障机制是什么?

在 Android 的消息机制中,同步屏障是一种特殊的机制,用于优先处理异步消息。

通常情况下,消息队列(MessageQueue)中的消息是按照顺序依次处理的,包括同步消息和异步消息。同步消息是普通的消息,它们会按照发送的先后顺序被取出和处理。但是在某些情况下,我们可能希望某些重要的或者紧急的消息能够优先处理,这时候就可以使用同步屏障。

同步屏障是一个特殊的消息标记,当插入一个同步屏障到消息队列中时,它会阻止后续的同步消息被取出,直到遇到异步消息。异步消息可以通过设置消息的异步标志来标记。例如,在 Android 的视图绘制过程中,一些重要的绘制相关的消息会被标记为异步消息,当插入同步屏障后,这些异步消息就可以跳过前面的同步消息,优先被处理,以保证视图能够及时地被绘制。

从内部实现机制来看,消息队列在处理消息时,会先检查是否有同步屏障。如果有同步屏障,会先查找异步消息,如果找到异步消息就先处理异步消息;如果没有找到异步消息,就会进入等待状态,直到有异步消息进入消息队列或者同步屏障被移除。

同步屏障的存在可以有效地提高系统的响应速度和性能。例如,在处理一些高优先级的任务时,如用户输入事件或者动画更新等,通过将这些任务标记为异步消息并且插入同步屏障,可以确保它们能够及时地得到处理,而不会被其他同步消息(如一些不太紧急的后台任务)所延迟。不过,同步屏障的使用需要谨慎,因为如果滥用同步屏障,可能会导致消息队列的混乱,一些同步消息可能会被长时间阻塞,影响系统的正常运行。

Android 布局优化有哪些方法?

在 Android 布局优化方面,有多种有效的方法。

首先是减少布局的嵌套层级。布局嵌套层级过深会导致性能下降,因为在绘制视图时,系统需要遍历整个视图树,层级越深,遍历的时间和资源消耗就越大。例如,尽量避免在 LinearLayout 中嵌套多个 LinearLayout,可以考虑使用 RelativeLayout 或者 ConstraintLayout 来减少层级。ConstraintLayout 是一种功能强大的布局方式,它通过约束来确定视图的位置,能够在复杂的布局场景下有效地减少嵌套层级。

合理使用布局复用也是一种重要的方法。可以通过使用<include>标签来复用布局文件。例如,在多个界面中都有相同的标题栏或者底部导航栏,就可以将这些公共部分提取出来,做成独立的布局文件,然后在需要的地方使用<include>标签引入。这样不仅可以减少代码的重复编写,还可以提高布局的加载速度。

另外,对于一些不经常变化的视图,可以考虑使用 ViewStub。ViewStub 是一个轻量级的视图,它在初始状态下是不可见的,并且不占用布局空间。当需要显示这个视图时,通过调用它的 inflate 方法来加载真正的视图。例如,在一个有多种状态(如加载中、加载成功、加载失败)的界面中,可以将加载成功后的复杂布局部分用 ViewStub 来表示,在数据加载成功后再加载这个布局,这样可以减少初始布局的加载时间和内存占用。

还可以优化视图的大小和绘制。例如,对于不需要透明效果的视图,不要设置 alpha 属性,因为透明效果会增加绘制的复杂性和资源消耗。并且,尽量给视图设置合适的大小,避免视图过大或者过小。过大的视图可能会导致内存浪费,过小的视图可能会影响显示效果,并且在某些情况下可能会导致多次重绘。

在布局加载方面,尽量避免在运行时频繁地加载布局文件。可以在 Activity 或者 Fragment 的生命周期早期(如 onCreate 方法)就加载布局,并且对于一些复杂的布局,可以考虑使用异步加载的方式,先显示一个简单的占位布局,等真正的布局加载完成后再替换,以提高用户体验。

LinearLayout 和 RelativeLayout 的区别,优缺点,层级嵌套等等等

LinearLayout 和 RelativeLayout 是 Android 中常用的两种布局方式,它们有许多区别。

从布局方式来看,LinearLayout 是线性布局,它按照水平或者垂直方向排列子视图。例如,当设置为水平方向时,子视图会从左到右依次排列;当设置为垂直方向时,子视图会从上到下依次排列。而 RelativeLayout 是相对布局,它通过相对位置来确定子视图的位置,子视图可以相对于父视图或者其他子视图进行定位,比如一个子视图可以位于另一个子视图的上方、下方、左侧或者右侧等。

在层级嵌套方面,LinearLayout 如果布局比较复杂,很容易导致嵌套层级过深。例如,要实现一个多行多列的布局,可能需要在 LinearLayout 中嵌套多个 LinearLayout。而 RelativeLayout 在一定程度上可以避免这种情况,因为它可以通过相对位置关系在一个布局中放置多个子视图,减少了布局的嵌套。

从优点方面来看,LinearLayout 的优点是简单直观,对于简单的线性排列的布局非常方便。例如,制作一个简单的列表项布局,使用 LinearLayout 可以快速地将文本视图、图标等子视图按照水平或者垂直方向排列好。而且 LinearLayout 在性能方面对于简单布局也有一定优势,因为它的布局计算相对简单。RelativeLayout 的优点是灵活性高,能够实现复杂的布局效果。例如,在制作一个不规则的界面布局,如带有重叠部分的界面或者需要精确控制子视图相对位置的布局时,RelativeLayout 能够很好地完成任务。

在缺点方面,LinearLayout 的主要缺点是布局复杂时容易出现嵌套层级过深的问题,这会影响性能和布局的维护性。另外,它对于一些相对位置比较复杂的布局实现起来比较困难。RelativeLayout 的缺点是布局的属性较多,相对复杂,在使用时需要对各个属性有比较深入的了解才能正确地布局子视图。而且在某些情况下,当布局中的视图数量较多且相对关系复杂时,RelativeLayout 的布局计算可能会消耗更多的资源,导致性能下降。

在实际应用中,可以根据具体的布局需求来选择使用 LinearLayout 还是 RelativeLayout。如果是简单的线性排列布局,LinearLayout 是一个很好的选择;如果是复杂的、需要精确控制相对位置的布局,RelativeLayout 可能更合适。同时,也可以考虑使用其他布局方式,如 ConstraintLayout 来综合它们的优点,实现更高效、更灵活的布局。

ConstrantLayout 讲讲特点

ConstraintLayout 是 Android 中一种功能强大的布局。

首先,它的布局灵活性非常高。通过约束条件来定义视图之间的位置关系,这些约束条件可以是视图与父视图之间的关系,也可以是视图与其他视图之间的关系。例如,一个视图可以被约束为在另一个视图的左侧一定距离处,或者在父视图的垂直中心位置。这种基于约束的布局方式使得它能够轻松地实现复杂的界面布局,无论是线性排列、相对排列还是不规则的排列都能很好地应对。

在布局性能方面,ConstraintLayout 也有出色的表现。它在构建布局时,通过优化的算法来计算视图的位置和大小,相比于一些传统布局方式(如多层嵌套的 LinearLayout),它能够减少布局的嵌套层级,从而减少布局渲染时遍历视图树的时间和资源消耗。而且,在处理动态布局变化时,它能够更高效地更新视图的位置和大小。例如,当屏幕方向发生改变或者视图的可见性发生变化时,ConstraintLayout 可以根据已有的约束条件快速地重新布局。

ConstraintLayout 还支持可视化的布局编辑。在 Android Studio 的布局编辑器中,开发者可以直观地通过拖拽视图和添加约束来构建布局,这大大提高了布局的构建效率,并且减少了布局代码出错的可能性。同时,它还提供了丰富的布局属性和工具,如链(Chains),可以方便地实现一组视图的均匀分布或者对齐等效果。

另外,它具有良好的兼容性。可以与其他布局方式结合使用,在已有的布局基础上添加 ConstraintLayout 来处理复杂的局部布局,或者将整个布局逐步转换为 ConstraintLayout 以提高布局性能和灵活性。

FrameLayout 了解么?

FrameLayout 是 Android 中最简单的布局之一。

它的主要特点是所有的子视图都会堆叠在布局的左上角。这就好像是把所有的子视图都放在一个相框(Frame)里,一个叠在另一个上面。例如,当在一个 FrameLayout 中添加多个视图时,后添加的视图会覆盖前面添加的视图,除非通过设置视图的透明度或者大小等属性来让它们部分或者全部显示出来。

在布局用途方面,FrameLayout 常用于一些简单的场景,比如作为容器来显示单个视图或者作为一个占位布局。例如,在实现一个加载动画时,可以将动画视图放在 FrameLayout 中,因为此时不需要考虑复杂的布局关系,只需要让动画在一个固定的位置显示即可。又比如,在使用 Fragment 时,FrameLayout 可以作为 Fragment 的容器,方便 Fragment 的切换和显示。

从性能角度来看,FrameLayout 的布局计算相对简单。因为它不需要考虑像 LinearLayout 那样的线性排列方向或者 RelativeLayout 那样复杂的相对位置关系,所以在加载和绘制布局时,它的速度比较快。这使得它在一些对性能要求较高且布局不复杂的场景下是一个很好的选择。

不过,FrameLayout 的局限性也很明显。由于它的堆叠式布局方式,对于复杂的、需要精确排列多个视图的布局场景,它就不太适用了。如果要实现多个视图的排列,如水平或者垂直排列、相对位置排列等,就需要结合其他布局方式或者通过设置子视图的属性来实现,这可能会增加布局的复杂性。

HashMap 底层实现原理是什么?

HashMap 是 Java 中用于存储键值对的常用数据结构。

在底层,HashMap 是基于数组和链表(在 Java 8 之后,当链表长度达到一定阈值时会转换为红黑树)来实现的。它有一个数组,这个数组的每个元素被称为桶(bucket)。当我们向 HashMap 中添加一个键值对时,首先会通过一个哈希函数来计算键的哈希值。这个哈希函数的作用是将键均匀地分布到数组的各个桶中。例如,对于一个简单的自定义哈希函数,可能会根据键的某些特征(如对象的内存地址、字符串的字符编码等)来生成一个整数哈希值。

计算出哈希值后,会通过一个计算(通常是哈希值对数组长度取模)来确定键值对应该存储在数组的哪个桶中。如果这个桶中还没有元素,就直接将键值对存储在这个桶中。如果桶中已经有元素(即发生了哈希冲突),就会以链表的形式将新的键值对添加到桶中(在 Java 8 之前一直是这种方式)。

在 Java 8 之后,当链表的长度达到一定阈值(默认为 8)时,为了提高查询效率,这个链表会转换为红黑树。红黑树是一种自平衡二叉搜索树,它的特点是能够保证在最坏情况下,查找、插入和删除操作的时间复杂度都是 O (logn)。这样,在处理哈希冲突较多的情况时,通过红黑树可以提高操作的效率。

在获取元素时,同样先计算键的哈希值,找到对应的桶,然后在桶中的链表或者红黑树中查找对应的键。如果找到键,就返回对应的键值。在删除元素时,也是先定位到桶,然后在桶中的链表或者红黑树中删除对应的键值对。

HashMap 还会根据存储的键值对数量和负载因子来动态地调整数组的大小。负载因子是一个阈值,当存储的键值对数量超过数组长度乘以负载因子时,就会对数组进行扩容,重新计算所有键的哈希值并重新分配键值对到新的桶中,以保证哈希表的性能。

HashMap(红黑树的时间效率为什么是 logn,怎么算出来的?)

在 HashMap 中,当红黑树用于处理哈希冲突时,其操作的时间复杂度为 O (logn)。

红黑树是一种自平衡二叉搜索树。二叉搜索树的特点是对于树中的任意节点,其左子树中的所有节点的值都小于该节点的值,其右子树中的所有节点的值都大于该节点的值。这种结构使得在查找一个元素时,可以通过比较元素的值与节点的值,快速地决定是在左子树还是右子树中继续查找。

对于红黑树,它在保持二叉搜索树特性的基础上,还具有红黑性质。红黑性质包括节点是红色或者黑色、根节点是黑色、叶子节点(NIL 节点)是黑色、每个红色节点的两个子节点都是黑色以及从任意节点到其每个叶子的所有路径都包含相同数目的黑色节点。这些性质保证了红黑树的平衡性。

假设红黑树的高度为 h,节点数为 n。由于红黑树的平衡性,其最长路径(从根节点到叶子节点)的长度不会超过 2log₂(n + 1)。在查找一个元素时,每次比较都可以将搜索范围缩小一半,就像在二分查找中一样。最坏的情况是需要从根节点一直查找到叶子节点,查找路径的长度就是树的高度 h。因为 h 是 O (logn) 级别,所以查找操作的时间复杂度为 O (logn)。

在插入和删除操作时,红黑树需要通过一系列的旋转和颜色调整操作来保持红黑性质和平衡性。这些操作的次数也是和树的高度 h 相关的,同样由于 h 是 O (logn) 级别,所以插入和删除操作的时间复杂度也为 O (logn)。

例如,当向红黑树中插入一个新节点时,首先会按照二叉搜索树的规则找到插入位置,然后通过旋转和颜色调整来保证红黑性质。这些操作都是在树的高度范围内进行的,不会因为节点数量的增加而导致操作时间呈线性增长。

ConcurrentHashMap 的实现原理是什么?

ConcurrentHashMap 是 Java 中用于在多线程环境下安全地存储键值对的数据结构。

在底层,ConcurrentHashMap 采用了分段锁的机制来实现高并发的读写操作。它将整个哈希表分成多个段(Segment),每个段相当于一个独立的小哈希表,有自己独立的锁。例如,在早期的实现中,默认有 16 个段。当多个线程同时访问 ConcurrentHashMap 时,不同的线程可以同时访问不同段中的元素,只要它们操作的不是同一个段,就不会发生冲突,从而可以实现更高的并发性能。

在写入操作(如 put 方法)时,如果要插入的键值对所在的段没有被其他线程锁定,就可以直接进行插入操作。如果段被锁定,线程会等待锁的释放,然后再尝试插入。在计算键的存储位置时,和普通的 HashMap 类似,先通过哈希函数计算哈希值,然后确定在哪个段和具体的桶中存储。

对于读取操作(如 get 方法),通常情况下,多个线程可以同时进行读取,因为读取操作不会修改数据结构的状态。但是在某些情况下,当读取操作发现正在进行并发的写入操作并且可能影响读取结果时,也会采取一些特殊的处理措施,以确保读取到的数据是正确的。

在 Java 8 之后,ConcurrentHashMap 的实现进行了一些优化。它不再完全依赖于分段锁,而是采用了一种更加细粒度的锁机制,结合了 CAS(比较并交换)操作和 synchronized 关键字。例如,对于哈希表中的单个桶,当插入或者删除元素时,会先尝试使用 CAS 操作来实现无锁的更新,如果 CAS 操作失败,再使用 synchronized 关键字来锁定桶,然后进行操作。这种混合的机制在保证高并发性能的同时,进一步提高了操作的效率和灵活性。

同时,ConcurrentHashMap 也会根据存储的键值对数量和负载因子等来动态地调整大小,在调整大小的过程中,也会采取一些措施来确保在多线程环境下的正确性和性能,比如通过多线程协作来分担数据迁移的任务。

说说内存优化有哪些方法?

内存优化是 Android 开发中非常重要的一部分,以下是一些常见的内存优化方法:

  • 优化对象的创建和使用:避免不必要的对象创建,比如在循环中频繁创建新的对象,可以将对象的创建放在循环体外。对于一些临时使用且占用内存较大的对象,及时将其置为 null,以便垃圾回收器能够及时回收内存。
  • 合理使用数据结构:根据不同的场景选择合适的数据结构。例如,在需要频繁查询和随机访问元素时,使用数组或 ArrayList 可能更合适;而在需要频繁插入和删除元素时,LinkedList 可能性能更好。同时,避免使用枚举类型,因为枚举类型在 Android 中会占用较多内存,可使用静态常量或其他替代方案.
  • 优化图片资源:在加载图片时,根据显示需求对图片进行适当的压缩和采样,以减少内存占用。可以使用 BitmapFactory.Options 来设置采样率,根据不同的屏幕分辨率和显示大小加载合适分辨率的图片,避免加载过大的图片导致内存溢出.
  • 避免内存泄漏:注意一些可能导致内存泄漏的情况,如注册广播接收器、事件监听器等未正确注销;持有外部类的引用导致内部类无法被释放;单例模式中不合理地持有 Context 等。确保在不再需要使用对象时,及时释放相关资源,解除引用关系.
  • 使用内存缓存:对于一些频繁使用且创建成本较高的对象,可以使用内存缓存来存储它们,下次需要使用时直接从缓存中获取,避免重复创建。例如,使用 LruCache 来缓存图片等资源,当缓存达到一定大小后,会自动删除最近最少使用的对象。
  • 优化布局文件:减少布局文件的嵌套层次,避免过度使用复杂的布局组件。可以使用 ConstraintLayout 等高效的布局方式来减少布局的嵌套,提高布局的性能和渲染效率 。
  • 及时释放资源:在使用完一些系统资源,如文件流、数据库连接、网络连接等后,要及时关闭和释放这些资源,以防止资源占用导致内存泄漏。

OOM 在什么情况下发生?

OOM(Out Of Memory)即内存溢出,通常在以下几种情况下发生:

  • 内存泄漏累积:当应用程序中存在内存泄漏时,不断有对象被分配内存但无法被垃圾回收器回收,随着时间的推移,可用内存逐渐减少,最终导致 OOM。例如,在 Activity 中注册了广播接收器,但在 Activity 销毁时未正确注销,导致广播接收器及其相关对象一直被持有,无法释放内存.
  • 加载大内存资源:如果一次性加载过多或过大的资源到内存中,超过了设备或应用程序所允许的内存限制,就会引发 OOM。比如加载超高分辨率的图片、大型的音频或视频文件等,而没有进行适当的压缩或处理.
  • 不合理的内存使用:在代码中存在一些不合理的内存使用方式,如创建大量不必要的对象、频繁地进行内存分配和释放操作等,导致内存碎片化严重,虽然总的内存占用可能并未超过限制,但可用的连续内存块不足,无法满足新的内存分配需求,从而引发 OOM 。
  • 持有过多的静态引用:静态变量的生命周期与应用程序的生命周期相同,如果在静态变量中持有大量的对象引用,这些对象将无法被释放,导致内存占用过高。例如,在一个工具类中定义了静态的集合对象,并不断向其中添加元素,而没有及时清理,就容易引发 OOM.
  • 递归调用导致栈溢出:如果在代码中存在无限递归或递归深度过深的情况,会导致栈内存不断被占用,最终栈内存耗尽,引发 OOM。虽然栈内存溢出严格来说不完全等同于 OOM,但也是一种内存相关的错误.
  • 第三方库的内存泄漏:使用的一些第三方库可能存在自身的内存泄漏问题,如果没有正确使用或处理,也会导致应用程序的内存泄漏和 OOM。在引入第三方库时,需要对其进行充分的测试和评估,确保其不会对应用程序的内存产生不良影响 。

怎么在线上收集 OOM 和内存泄漏?

在线上收集 OOM 和内存泄漏是确保应用程序稳定性和性能的重要环节,以下是一些常见的方法:

  • 使用 Android 系统提供的工具:
    • ANR WatchDog:虽然主要用于检测 ANR(Application Not Responding),但在某些情况下也可以间接检测到一些导致 OOM 或内存泄漏的问题。例如,如果应用程序因为内存不足而长时间无响应,ANR WatchDog 会触发相应的回调,通过分析 ANR 发生时的堆栈信息等,可以初步判断是否存在内存相关的问题。
    • Dumpsys:可以通过命令行或在代码中调用 dumpsys 命令来获取系统服务的状态信息,包括内存使用情况等。例如,使用 dumpsys meminfo 命令可以查看应用程序的内存分配和使用详情,分析各个进程、组件以及对象的内存占用情况,有助于发现内存泄漏和异常的内存增长。
  • 集成第三方内存监测工具:
    • LeakCanary:这是一款专门用于检测内存泄漏的开源库,通过在应用程序中集成 LeakCanary,可以自动检测和分析内存泄漏问题。它会在后台定期检查内存中的对象引用关系,当发现可能存在的内存泄漏时,会生成详细的泄漏报告,包括泄漏的对象、引用链等信息,帮助开发者快速定位和解决问题。
    • MAT(Memory Analyzer Tool):虽然 MAT 通常用于线下的内存分析,但也可以在一些特殊情况下用于线上内存问题的初步排查。通过获取应用程序的内存快照(hprof 文件),然后使用 MAT 进行分析,可以查看对象的内存分布、引用关系等,找出可能存在的内存泄漏和内存占用过高的问题。不过,获取线上内存快照可能需要一些额外的配置和权限。
  • 自定义日志和监控:
    • 内存监控日志:在应用程序中添加自定义的内存监控日志,记录关键对象的创建、销毁以及内存使用情况的变化。通过分析这些日志,可以了解应用程序的内存使用趋势,及时发现异常的内存增长和可能的内存泄漏点。例如,可以记录每次 Activity 或 Fragment 的生命周期方法中的内存使用情况,以及重要对象的创建和释放时间。
    • 异常监控和上报:建立全局的异常捕获机制,当发生 OOM 或其他与内存相关的异常时,将异常信息以及相关的上下文信息(如当前的操作、页面等)上报到服务器。这样,开发人员可以及时获取线上发生的内存问题,进行进一步的分析和处理。
  • 云服务平台的监控:许多云服务平台提供了应用性能监控(APM)服务,其中包括对内存使用情况的监控。通过在应用程序中集成相应的 SDK,可以将应用程序的内存数据上传到云平台,开发人员可以在云平台上查看实时的内存指标、趋势图以及异常报警等,及时发现和解决线上的内存问题 。

Leakcanary 的原理?

Leakcanary 是一款用于检测 Android 应用中内存泄漏的强大工具,其原理主要基于以下几个关键步骤:

  • 对象观察与弱引用:Leakcanary 通过对应用中可能产生内存泄漏的对象进行观察来检测泄漏。它会将需要检测的对象包装在弱引用中,并将这些弱引用对象放入一个特殊的队列中。弱引用的特点是,当所引用的对象没有其他强引用指向时,垃圾回收器会自动回收该对象,并将弱引用放入一个关联的引用队列中。
  • 内存泄漏检测触发时机:通常情况下,Leakcanary 会在 Android 应用的生命周期回调中,如 Activity 的 onDestroy 方法被调用后,开始检查是否存在内存泄漏。当一个 Activity 被销毁后,如果它所对应的弱引用对象没有被放入引用队列中,即表示该 Activity 可能存在内存泄漏,因为它没有被垃圾回收器正常回收。
  • 堆内存快照分析:一旦怀疑存在内存泄漏,Leakcanary 会触发堆内存快照的生成。它使用 Android 系统提供的 Debug.dumpHprofData 方法来获取当前应用的堆内存快照,这个快照包含了应用程序在某一时刻内存中所有对象的信息,包括对象的类型、实例数量、内存占用大小以及对象之间的引用关系等。
  • 引用链分析与泄漏定位:获取到堆内存快照后,Leakcanary 会使用 Shark 等分析工具对快照进行解析和分析。它会从疑似泄漏的对象开始,沿着对象的引用链进行查找,找出所有持有该对象强引用的其他对象,从而确定导致内存泄漏的根本原因。通过分析引用链,可以清晰地看到哪些对象之间存在不合理的引用关系,导致被泄漏的对象无法被释放。
  • 泄漏报告生成:最后,Leakcanary 会根据分析结果生成详细的内存泄漏报告。报告中会包含泄漏的对象信息、引用链的详细路径、泄漏的可能原因以及相关的建议等,帮助开发者快速定位和解决内存泄漏问题 。

tcp 和 udp 的区别是什么?

TCP(Transmission Control Protocol)和 UDP(User Datagram Protocol)是两种常见的传输层协议,它们在以下方面存在区别:

  • 连接方式:
    • TCP 是面向连接的协议,在数据传输之前,需要先建立连接,即通过三次握手来确认双方的连接状态,数据传输完成后,还需要通过四次挥手来关闭连接。这种连接方式确保了数据传输的可靠性和顺序性,但建立和关闭连接的过程会消耗一定的时间和系统资源。
    • UDP 是无连接的协议,不需要在发送数据前建立连接,直接将数据报发送给目标地址。因此,UDP 的传输效率相对较高,但由于没有连接的保证,数据的传输可靠性相对较低,可能会出现数据丢失、重复或乱序的情况。
  • 可靠性:
    • TCP 提供可靠的数据传输服务。它通过序列号、确认应答、重传机制等保证数据能够准确无误地到达目的地。发送方会对发送的数据进行编号,接收方收到数据后会返回确认应答,发送方根据确认应答来判断数据是否被正确接收,如果在一定时间内未收到确认应答,则会重发数据,直到数据被正确接收为止。
    • UDP 不保证数据的可靠传输,它只是尽可能地将数据报发送出去,但不关心数据是否能够到达目的地,也不进行数据的重传和确认。因此,UDP 适用于对实时性要求较高,但对数据准确性要求相对较低的场景,如视频直播、音频通话等。
  • 数据传输效率:
    • TCP 由于需要建立连接、维护连接状态以及进行数据的确认和重传等操作,其传输效率相对较低。尤其是在数据量较小且对实时性要求较高的情况下,TCP 的连接建立和拆除过程会对性能产生较大的影响。
    • UDP 没有连接建立和拆除的开销,数据报的格式也相对简单,因此传输效率较高。UDP 可以快速地将数据发送出去,适合于对实时性要求较高的应用场景,如实时游戏、在线视频会议等。
  • 数据报大小:
    • TCP 对数据报的大小没有明确的限制,它会根据网络状况和接收方的窗口大小自动调整数据的发送量,以充分利用网络带宽和提高传输效率。
    • UDP 数据报的大小是有限制的,一般情况下,UDP 数据报的最大长度为 65535 字节,其中包括 UDP 头部和数据部分。如果数据量超过了这个限制,就需要对数据进行分片和重组,增加了数据传输的复杂性。
  • 适用场景:
    • TCP 适用于对数据传输可靠性要求较高的场景,如文件传输、电子邮件、网页浏览等。在这些场景中,数据的完整性和准确性至关重要,即使传输速度稍慢一些,也需要确保数据能够正确无误地到达目的地。
    • UDP 适用于对实时性要求较高,而对数据准确性要求相对较低的场景,如实时视频流、音频流、在线游戏等。在这些场景中,少量的数据丢失或错误对用户体验的影响相对较小,更重要的是保证数据的实时传输和快速响应 。

OSI 七层模型,tcpip 四层模型是什么?

OSI(Open System Interconnection)七层模型是一个开放式的网络通信参考模型。从下到上依次为物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。

物理层主要涉及物理介质,像电缆、光纤等,它定义了设备之间的物理连接标准,包括接口的形状、引脚数量、信号的传输方式等。比如,不同类型的网线(如双绞线、同轴电缆)在物理层有不同的特性。

数据链路层负责将物理层接收到的信号转换为数据帧,并进行错误检测和纠正。它主要关注的是在物理连接上可靠地传输数据帧,像以太网协议就工作在这一层,通过 MAC 地址来识别网络中的设备。

网络层主要负责寻址和路由选择。它将数据从源节点传输到目标节点,IP 协议就处于这一层,通过 IP 地址来确定数据的传输路径,能够跨越不同的网络进行通信。

传输层提供端到端的通信服务,主要有 TCP 和 UDP 协议。这一层负责将上层应用的数据进行分割、封装,并确保数据能够正确地传输到目标应用程序。

会话层用于建立、管理和终止会话。它协调不同主机之间的通信会话,比如在进行文件传输时,会话层会管理文件传输的开始、暂停和结束。

表示层主要处理数据的表示、转换和加密。它使得不同系统之间能够理解彼此的数据格式,比如将数据从 ASCII 码转换为 EBCDIC 码等。

应用层是最接近用户的一层,它提供各种网络应用服务,像 HTTP、FTP、SMTP 等协议都在这一层,直接为用户提供服务,如网页浏览、文件传输、电子邮件发送等。

TCP/IP 四层模型是实际应用更为广泛的网络模型。它包括网络接口层,对应 OSI 模型的物理层和数据链路层,负责将数据帧发送到网络上或者从网络上接收数据帧;网络层,主要功能是 IP 寻址和路由选择,与 OSI 模型的网络层类似;传输层,包含 TCP 和 UDP 协议,提供端到端的通信服务;应用层,涵盖了各种应用协议,和 OSI 模型的应用层、表示层和会话层的功能类似,用于实现各种网络应用服务。

TCP 三次握手四次挥手的过程是怎样的?

TCP 三次握手用于建立连接。

第一次握手,客户端向服务器发送一个 SYN(同步)包,这个包中包含一个随机生成的初始序列号(Sequence Number),假设为 x。此时客户端处于 SYN_SENT 状态,这个状态表示客户端已经发送了连接请求,等待服务器的回应。

第二次握手,服务器收到客户端的 SYN 包后,会返回一个 SYN - ACK(同步 - 确认)包。这个包中包含两个重要的信息,一是服务器自己生成的初始序列号,假设为 y,另一个是对客户端序列号的确认,确认号为 x + 1。此时服务器处于 SYN_RCVD 状态,意味着服务器已经收到客户端的连接请求,并且发送了自己的同步信息和确认信息,等待客户端的进一步回应。

第三次握手,客户端收到服务器的 SYN - ACK 包后,会向服务器发送一个 ACK(确认)包,确认号为 y + 1。此时客户端进入 ESTABLISHED 状态,这个状态表示连接已经成功建立。服务器收到客户端的 ACK 包后,也进入 ESTABLISHED 状态,这样双方就建立起了可靠的 TCP 连接,可以开始进行数据传输。

TCP 四次挥手用于断开连接。

第一次挥手,主动关闭方(假设是客户端)发送一个 FIN(结束)包,表示自己不再发送数据,但是还可以接收数据。此时客户端进入 FIN_WAIT_1 状态。

第二次挥手,服务器收到客户端的 FIN 包后,会返回一个 ACK 包,表示已经收到客户端的关闭请求。此时服务器进入 CLOSE_WAIT 状态,这个状态下服务器还可以继续发送数据,客户端收到 ACK 包后进入 FIN_WAIT_2 状态。

第三次挥手,当服务器也准备好关闭连接时,会发送一个 FIN 包给客户端,表示自己也不再发送数据。此时服务器进入 LAST_ACK 状态。

第四次挥手,客户端收到服务器的 FIN 包后,会发送一个 ACK 包给服务器,确认收到服务器的关闭请求。然后客户端进入 TIME_WAIT 状态,这个状态会持续一段时间(2MSL,MSL 是最长报文段寿命),主要是为了确保服务器能够收到客户端的最后一个 ACK 包,避免因为网络延迟等原因导致服务器重复发送 FIN 包。当等待时间结束后,客户端才会真正关闭连接,进入 CLOSED 状态。服务器收到客户端的 ACK 包后,也会立即关闭连接,进入 CLOSED 状态。

HTTP 和 HTTPS 的区别是什么?

HTTP(HyperText Transfer Protocol)是超文本传输协议,是一种用于在 Web 上传输数据的协议。

它是一种明文传输协议,数据在网络上是以明文的形式发送的。这就意味着,在传输过程中,信息很容易被中间人截获并读取。例如,如果在一个不安全的公共 Wi - Fi 环境下使用 HTTP 访问网站,攻击者可以通过抓包工具获取用户的登录账号和密码等敏感信息。

HTTP 的端口号通常是 80。当浏览器访问一个网站时,如果没有指定协议或者指定为 http://,就会默认使用 80 端口进行通信。

它的通信过程相对简单,客户端(浏览器)向服务器发送请求,服务器收到请求后返回响应。这种简单的通信方式使得 HTTP 在早期的网络环境中得到了广泛的应用,但是由于其安全性不足,在一些对安全要求较高的场景下逐渐被替代。

HTTPS(HyperText Transfer Protocol Secure)是在 HTTP 的基础上加入了 SSL/TLS(Secure Sockets Layer/Transport Layer Security)加密协议的安全版本。

HTTPS 通过加密来保证数据的安全性。在通信开始前,客户端和服务器会进行 SSL/TLS 握手,协商加密算法和密钥。在数据传输过程中,使用协商好的密钥对数据进行加密,这样即使数据被中间人截获,也无法读取其中的内容。例如,在网上银行的交易过程中,使用 HTTPS 可以确保用户的资金信息和交易信息的安全。

HTTPS 的端口号通常是 443。当使用 https:// 访问网站时,浏览器会通过 443 端口与服务器进行加密通信。

另外,由于 HTTPS 需要进行加密和解密操作,以及 SSL/TLS 握手等额外的步骤,其性能相对 HTTP 会有所下降。不过,随着计算机硬件性能的提升和加密技术的优化,这种性能差距在逐渐缩小。而且,为了网站的安全和用户信息的保护,越来越多的网站开始使用 HTTPS 作为默认的通信协议。

创建线程的方式有哪些?

在 Java(Android 也是基于 Java)中有多种创建线程的方式。

一种是通过继承 Thread 类来创建线程。首先创建一个类继承自 Thread 类,然后重写 run 方法。在 run 方法中定义线程要执行的任务。例如,定义一个简单的线程类:

class MyThread extends Thread {
    @Override
    public void run() {
        // 线程要执行的任务,比如打印一些信息
        System.out.println("线程正在运行");
    }
}

然后在其他地方创建这个线程类的实例并启动线程:

MyThread thread = new MyThread();
thread.start();

需要注意的是,必须通过 start 方法来启动线程,而不是直接调用 run 方法。如果直接调用 run 方法,就只是在当前线程中执行了 run 方法中的代码,而没有真正创建新的线程。

另一种方式是通过实现 Runnable 接口来创建线程。首先定义一个类实现 Runnable 接口,在这个类中实现 run 方法来定义线程任务。例如:

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("通过Runnable创建的线程正在运行");
    }
}

然后可以通过将这个 Runnable 实例传递给 Thread 类的构造函数来创建线程并启动:

MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
thread.start();

这种方式的优点是可以避免单继承的限制,因为一个类可以实现多个接口,并且可以将线程任务和线程对象本身分离,使得代码结构更加灵活。

还可以通过使用 Callable 和 Future 来创建线程。Callable 接口类似于 Runnable 接口,但是它可以有返回值,并且可以抛出异常。定义一个实现 Callable 接口的类,在 call 方法中定义线程任务并返回结果。例如:

import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
 
class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        return 10;
    }
}

然后通过 FutureTask 来包装 Callable 实例,再将 FutureTask 传递给 Thread 类来创建和启动线程:

MyCallable callable = new MyCallable();
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();
// 可以通过futureTask.get()来获取Callable的返回值

这种方式适合需要在线程执行完毕后获取返回值的场景。

进程和线程区别是什么?

进程是操作系统中独立的执行单位,它拥有自己独立的地址空间、内存、数据栈等资源。

从资源占用角度看,每个进程都有自己独立的内存空间,这意味着不同进程之间的数据是相互隔离的。例如,一个进程中的变量和代码段在另一个进程中是无法直接访问的。进程在操作系统中的资源分配是相对独立的,包括内存、文件描述符等资源。当一个进程启动时,操作系统会为它分配一系列的资源,这些资源只供这个进程使用。

在执行过程中,进程是一个独立的实体,有自己的程序计数器、寄存器等。它可以独立地执行程序代码,并且可以通过操作系统提供的机制(如进程间通信)与其他进程进行交互。例如,一个进程可以通过管道、消息队列等方式与其他进程交换数据。

从操作系统调度角度看,进程是操作系统进行资源分配和调度的基本单位。操作系统会根据进程的优先级、资源需求等因素来分配 CPU 时间片,每个进程在自己的时间片内执行。

线程是进程中的一个执行单元,它共享进程的资源,包括内存空间、文件描述符等。

线程没有自己独立的地址空间,它和同一进程中的其他线程共享进程的内存。这使得线程之间的数据共享变得非常方便。例如,在一个多线程的服务器应用程序中,多个线程可以共享同一个数据库连接池或者缓存。

在执行过程中,多个线程可以并发地执行,它们共享进程的程序计数器和寄存器等资源。但是每个线程都有自己独立的栈空间,用于存储局部变量和函数调用的上下文。

从操作系统调度角度看,线程是操作系统进行调度的轻量级单位。由于线程共享进程的资源,线程之间的切换成本相对较低。操作系统可以快速地在不同线程之间切换,使得多个线程可以并发地执行,提高了系统的执行效率。

总的来说,进程是资源分配的基本单位,相对独立;线程是进程中的执行单位,共享进程的资源,能够更高效地利用系统资源实现并发执行。

死锁是如何发生的?

死锁是在多线程或多进程环境下,由于资源竞争和不合理的资源分配顺序而导致的一种阻塞状态。

假设存在两个线程 A 和 B,以及两个资源 R1 和 R2。线程 A 先获取了资源 R1,并且在获取 R1 后,它需要再获取资源 R2 才能继续执行任务。同时,线程 B 获取了资源 R2,并且它也需要获取资源 R1 才能继续。这样,线程 A 在等待资源 R2 被释放,而线程 B 在等待资源 R1 被释放,双方都无法继续前进,就形成了死锁。

从资源分配的角度看,当多个线程对多个资源进行加锁操作时,如果加锁顺序不一致,就容易出现死锁。例如,在一个复杂的数据库应用程序中,有多个事务操作不同的数据库表。如果一个事务先锁住表 A,然后尝试锁住表 B,而另一个事务先锁住表 B,然后尝试锁住表 A,当两个事务同时执行时,就可能导致死锁。

死锁的产生还和资源的独占性有关。如果资源不是独占的,多个线程可以同时访问,就不会出现死锁。但在很多情况下,像文件、数据库连接、打印机等资源,在同一时间只能被一个线程或进程使用。当多个线程竞争这些独占资源并且形成了相互等待的闭环时,死锁就发生了。

另外,信号量的不合理使用也可能导致死锁。信号量用于控制对共享资源的访问数量。如果在使用信号量时,没有正确地考虑资源的分配和释放顺序,也会出现类似锁的死锁情况。例如,多个线程通过信号量来控制对缓冲区的访问,当信号量的初始值设置不当或者信号量的 P 操作(请求资源)和 V 操作(释放资源)顺序混乱时,就可能导致死锁。

在实际的系统中,死锁的检测和避免是非常重要的。可以通过资源分配图来检测死锁,在资源分配图中,节点表示进程和资源,边表示进程对资源的请求和分配。如果资源分配图中存在环,就可能存在死锁。为了避免死锁,可以采用一些策略,如规定资源的获取顺序,所有线程按照相同的顺序获取资源;或者采用银行家算法,在分配资源之前先判断这种分配是否会导致系统进入不安全状态,从而避免死锁的发生。

volatile 关键字的作用是什么?

volatile 关键字在 Java(包括 Android 开发中,因为 Android 基于 Java)中有重要的作用。

首先,它用于保证变量的可见性。在多线程环境下,每个线程都有自己的工作内存,线程对变量的操作首先是在自己的工作内存中进行的,而不是直接操作主内存中的变量。当一个线程修改了一个普通变量的值后,这个新的值可能不会马上被其他线程看到。但是如果一个变量被声明为 volatile,那么当这个变量的值被一个线程修改后,其他线程能够立即看到这个修改后的值。

例如,有一个共享变量count,被两个线程 A 和 B 访问。如果count是普通变量,线程 A 修改了count的值,线程 B 可能不会马上察觉到这个变化。但如果count是volatile变量,当线程 A 修改count后,线程 B 在下次访问count时,能够获取到最新的值。

其次,volatile 关键字可以禁止指令重排序。在现代处理器和编译器中,为了提高程序的执行效率,会对指令进行重排序。指令重排序是指编译器和处理器为了优化程序性能,在不改变单线程程序语义的前提下,对指令的执行顺序进行调整。然而,在多线程环境下,指令重排序可能会导致一些问题。

例如,有一段代码,先对一个共享变量flag进行赋值,然后基于flag的值执行一些其他操作。如果没有volatile关键字,编译器可能会对这些指令进行重排序,导致其他线程在看到flag被赋值后,执行相关操作时,发现操作所依赖的其他条件还没有准备好。而使用volatile关键字后,可以保证代码的执行顺序符合程序的逻辑顺序,防止这种由于指令重排序而导致的错误。

但是需要注意的是,volatile 关键字并不能保证原子性。例如,对于一个volatile变量进行自增操作(count++),这个操作不是原子的,它包含了读取变量、修改变量和写入变量三个步骤。在多线程环境下,多个线程同时对这个volatile变量进行自增操作时,可能会出现数据不一致的情况。

代码层面怎么去做瘦身优化?

在代码层面进行瘦身优化可以从多个方面入手。

首先是代码结构的优化。减少不必要的代码嵌套,例如在条件判断语句中,如果存在多层嵌套的if - else语句,可以考虑通过逻辑判断的转换来减少嵌套层次。比如,将多个if条件判断转换为switch语句,在某些情况下可以使代码结构更加清晰,减少嵌套。同时,避免冗余的代码,对于重复出现的代码片段,可以将其提取为方法或者类,这样不仅可以减少代码量,还可以提高代码的可维护性。

在资源使用方面,要合理地引用库。只引入项目真正需要的库,避免引入一些庞大但只有部分功能被使用的库。对于一些功能相似的库,可以进行评估,选择更轻量级的库。例如,在处理 JSON 数据时,有多种 JSON 解析库可供选择,有些库功能强大但体积较大,有些则相对轻量级,根据项目的实际需求选择合适的库可以有效减少代码的体积。

在代码中,优化字符串的使用也很重要。避免使用过多的字符串拼接,尤其是在循环中。因为字符串是不可变对象,每次拼接都会创建新的字符串对象,消耗内存。可以使用StringBuilder或者StringBuffer(在多线程环境下更适合使用StringBuffer)来进行字符串的拼接。例如,在构建一个较长的 SQL 查询语句或者拼接一个复杂的文本内容时,使用StringBuilder可以提高性能并且减少内存占用。

对于资源文件,如图片、音频和视频等,要进行合理的压缩和优化。在 Android 开发中,对于不同分辨率的设备,提供合适分辨率的图片,避免在低分辨率设备上加载高分辨率的图片,这不仅可以减少应用的体积,还可以降低内存占用。对于音频和视频文件,采用合适的编码格式和分辨率,以平衡文件质量和文件大小。

另外,在代码中要注意及时清理无用的资源和对象。例如,在使用完文件流、数据库连接等资源后,及时关闭它们,避免资源的浪费和潜在的内存泄漏。对于一些临时创建的对象,当它们不再使用时,将其设置为null,以便垃圾回收器能够及时回收内存,减少内存占用。

在方法层面,尽量使方法的功能单一,避免一个方法包含过多复杂的功能。这样可以使方法的代码量减少,并且在调用方法时,减少不必要的参数传递和计算。同时,对于一些只在特定条件下使用的方法,可以考虑将其设置为private或者protected,减少方法的暴露范围,这样在代码打包时,编译器可能会对这些方法进行更好的优化。

几种热修复方案的原理及优缺点?

热修复是一种在应用已经发布后,能够在不重新发布整个应用的情况下,修复应用中的问题(如 Bug)的技术。

  1. 基于 Native Hook 的热修复方案

原理:这种方案主要是通过修改底层 Native 层的函数指针来实现热修复。在 Native 层,函数的调用是通过函数指针来实现的。热修复工具可以在运行时,将出现问题的 Native 函数指针替换为新的函数指针,这个新的函数就包含了修复后的代码。例如,当一个 Native 库中的函数存在内存泄漏问题时,通过这种方式可以替换该函数,使得应用在运行时能够使用修复后的函数。

优点:它对性能的影响相对较小,因为 Native 层的操作比较接近底层硬件,一旦修复完成,函数的执行效率和原来相差不大。而且这种方式可以修复一些比较底层的、和硬件交互或者性能敏感的 Native 代码问题。

缺点:技术难度较高,需要对 Native 开发和底层系统调用有深入的了解。并且这种修复方式可能会受到操作系统版本和硬件设备的限制,因为不同的操作系统版本和硬件对 Native 函数的调用机制和内存布局可能会有所不同。另外,这种方式可能会涉及到一些安全风险,如非法的函数指针替换可能会导致系统崩溃或者安全漏洞。

  1. 基于 Java 字节码插桩的热修复方案

原理:在 Java(包括 Android 的 Java 代码)中,字节码是 Java 代码编译后的中间形式。热修复方案可以在字节码层面进行操作。通过在运行时将修复后的字节码插入到已加载的类中,来替换原来有问题的字节码。例如,当一个 Java 方法存在逻辑错误时,将修改后的字节码注入到对应的类中,使得下次调用这个方法时,执行的是修复后的逻辑。

优点:可以修复 Java 层的大部分问题,包括方法逻辑错误、异常处理不当等。而且这种方式相对比较灵活,不需要重新编译整个应用,只需要生成和注入修复后的字节码即可。

缺点:可能会对应用的性能产生一定的影响,因为字节码的插入和替换操作需要在运行时进行,会增加一定的时间和内存开销。并且如果操作不当,可能会导致类加载的混乱,比如出现类的版本冲突或者无法正确加载类的情况。另外,这种方式对于一些被系统或者其他第三方库高度优化的字节码可能无法很好地进行修复,因为这些字节码可能有特殊的加载和执行机制。

  1. 基于类加载器的热修复方案

原理:利用 Java 的类加载器机制,为修复后的类创建一个新的类加载器。当需要加载修复后的类时,通过这个新的类加载器来加载,使得应用能够使用修复后的类,而不是原来有问题的类。例如,在 Android 开发中,当一个 Activity 类存在 Bug 时,通过创建新的类加载器加载修复后的 Activity 类,在运行时替换原来的 Activity 类。

优点:这种方式可以很好地隔离修复后的类和原来的类,避免了类之间的冲突。并且可以灵活地控制类的加载顺序和范围,对于一些局部的问题修复比较有效。

缺点:会增加内存的占用,因为每个新的类加载器都会占用一定的内存空间。而且如果类加载器的管理不当,可能会导致类加载的混乱,比如出现内存泄漏或者无法正确卸载类的情况。另外,这种方式对于一些依赖关系复杂的类可能会面临挑战,因为需要重新梳理类之间的依赖关系,以确保新加载的类能够正确地工作。

虚拟机栈中为啥会有局部变量表?它的设计初衷是什么?

虚拟机栈是 Java 虚拟机(JVM)运行时数据区的一个重要组成部分,局部变量表在其中起着关键的作用。

在方法调用过程中,局部变量表用于存储方法中的局部变量。当一个方法被调用时,虚拟机栈会为这个方法创建一个栈帧,栈帧是虚拟机栈中的一个基本单位,而局部变量表就是栈帧的一部分。

从数据存储角度看,局部变量表的设计初衷是为了高效地存储方法中的变量。在一个方法中,可能会有各种类型的局部变量,包括基本数据类型(如int、double等)和对象引用。局部变量表提供了一个固定大小的存储区域,可以按照变量的定义顺序来存储这些变量。例如,在一个简单的方法中,有一个int变量和一个对象引用变量,它们会被依次存储在局部变量表中。

在方法执行过程中,需要频繁地访问和操作这些局部变量。局部变量表的存在使得这些操作能够快速地进行。因为变量存储在栈帧的局部变量表中,虚拟机在执行字节码指令时,可以直接通过偏移量来访问这些变量,而不需要在内存中进行复杂的查找操作。这种基于偏移量的访问方式类似于数组的索引访问,能够提高变量访问的速度。

从方法的独立性和封装性角度看,局部变量表有助于实现方法的独立性。每个方法都有自己独立的局部变量表,这使得方法内部的变量不会受到其他方法的干扰。例如,在一个多线程环境下,不同线程调用同一个方法时,每个线程的方法调用栈帧中的局部变量表是相互独立的,这样可以保证每个线程在执行方法时,变量的状态是独立的,符合方法的封装性原则。

另外,局部变量表的大小在编译时就基本确定了(对于动态扩展的情况相对较少),这有助于虚拟机在运行时更好地管理内存。它可以根据方法的字节码信息提前分配好局部变量表的空间,避免在方法执行过程中频繁地进行内存分配和调整,提高了方法执行的效率和内存管理的稳定性。

四大引用的区别是什么?

在 Java(包括 Android 开发)中有四种引用类型,分别是强引用、软引用、弱引用和虚引用,它们之间存在明显的区别。

强引用是最常见的引用类型。当我们通过new关键字创建一个对象并将其赋值给一个变量时,这个变量对对象的引用就是强引用。例如,Object obj = new Object();,这里obj对Object对象就是强引用。只要强引用存在,垃圾回收器就不会回收被引用的对象。这意味着在内存不足的情况下,即使抛出OutOfMemory错误,也不会回收这些对象,直到强引用被释放。

软引用是一种相对较弱的引用。它主要用于描述一些还有用但非必需的对象。当内存空间足够时,软引用的对象不会被回收;但当内存不足时,垃圾回收器会优先回收软引用指向的对象。软引用通常用于实现缓存机制,比如在内存充足的情况下,将一些频繁访问但又可以重新创建的数据存储在软引用中,当内存紧张时可以自动释放这些数据来节省内存。

弱引用比软引用更弱。一旦垃圾回收器发现了只被弱引用指向的对象,就会立即回收该对象,而不会考虑内存是否充足。弱引用主要用于在不影响对象生命周期的情况下,允许对象在合适的时候被回收。例如,在一些容器类中,如果容器中的元素只被弱引用所指向,当没有其他强引用指向这些元素时,元素就会被回收,这样可以避免容器中积累大量无用的元素。

虚引用是最弱的一种引用。虚引用的对象几乎等同于没有引用,主要用于在对象被回收时收到一个系统通知。它不能单独使用,必须和引用队列联合使用。当垃圾回收器准备回收一个对象时,如果发现它只有虚引用,就会在回收该对象后,将这个虚引用加入到引用队列中,通过检测引用队列可以知道对象是否已经被回收。这种引用类型通常用于一些特殊的场景,如在对象被回收后进行一些资源清理或者统计等工作。

GC 内存回收机制?Android 和 Java 中有什么区别?

在 Java 中,GC(垃圾回收)机制是自动管理内存的重要手段。

Java 的 GC 主要是通过标记 - 清除算法、复制算法、标记 - 整理算法等来回收不再使用的对象所占用的内存。标记 - 清除算法首先会标记出所有从根对象(如栈中的变量、静态变量等)可达的对象,然后清除那些没有被标记的对象。复制算法则是将内存分为大小相等的两块,每次只使用其中一块,当这一块内存满了,就将存活的对象复制到另一块内存中,然后清除原来那块内存中的所有对象。标记 - 整理算法结合了标记 - 清除和复制算法的优点,它先标记出存活的对象,然后将存活的对象向一端移动,最后清理掉边界以外的内存。

在 Android 中,GC 机制和 Java 基本相似,但也有一些不同之处。

Android 设备的内存资源相对更加有限,所以 Android 的 GC 更加注重内存的高效利用。例如,Android 系统会更频繁地触发 GC 来避免内存耗尽。同时,Android 系统中有自己独特的内存管理策略,比如在应用的生命周期管理中,当应用进入后台时,系统会倾向于回收该应用占用的内存,以保证前台应用有足够的内存。

另外,Android 开发中需要考虑更多的因素来避免内存泄漏。因为 Android 应用中有许多和系统组件(如 Activity、Service 等)相关的对象,这些对象的生命周期和用户操作以及系统状态密切相关。如果在开发过程中没有正确处理这些对象的引用,很容易导致内存泄漏,而这会影响 GC 的效果。例如,在 Activity 销毁后,如果还有强引用指向该 Activity,那么这个 Activity 及其相关的资源就无法被 GC 回收。

而且,Android 系统还会对不同类型的内存(如堆内存、栈内存等)有不同的管理方式。堆内存主要用于存储对象,而栈内存用于存储方法调用的相关信息。在 Android 中,对堆内存的 GC 操作更为复杂,因为需要考虑不同类型的对象(如大对象、小对象等)以及它们在内存中的布局和分配情况。

so 的编译过程,静态库和动态库的区别,动态链接是什么?

so 文件的编译过程

在 Android 开发中,.so文件是共享库文件(动态库)。它的编译过程较为复杂。首先,源文件(通常是用 C 或 C++ 编写)经过预处理,这个过程主要是处理一些预处理指令,如#include、#define等。预处理会将头文件的内容包含进来,并且进行宏替换等操作。

然后是编译阶段,编译器将预处理后的源文件转换为汇编语言文件。在这个阶段,编译器会检查语法错误、进行词法分析和语法分析等操作,并且根据源文件中的代码逻辑生成对应的汇编指令。

接着是汇编阶段,汇编器将汇编语言文件转换为目标文件(.o文件)。目标文件是一种二进制文件,它包含了机器代码和一些符号信息,这些符号信息用于在链接阶段进行符号解析。

最后是链接阶段,对于动态库(.so文件),链接器会将多个目标文件以及一些必要的库文件(如系统库)链接在一起,生成共享库文件。在链接过程中,会处理符号的重定位和解析,使得库中的函数和变量能够正确地被调用。

静态库和动态库的区别

静态库在链接阶段会将库文件中的代码和数据复制到最终的可执行文件中。这意味着,一旦程序链接了静态库,就不再依赖库文件本身,可执行文件包含了所有需要的代码。例如,一个静态库中有函数A和B,当一个程序链接了这个静态库后,函数A和B的代码就被复制到了程序的可执行文件中。

动态库则不会将代码复制到可执行文件中,而是在程序运行时,当需要调用库中的函数或访问库中的数据时,才加载动态库。这样,多个程序可以共享同一个动态库,节省了内存和磁盘空间。例如,多个 Android 应用可以共享同一个系统动态库,只有在应用需要使用该库中的功能时才会加载。

静态库的优点是可移植性强,因为可执行文件包含了所有的代码,不依赖外部的库文件,在不同的环境下更容易运行。缺点是会使可执行文件体积增大,并且如果静态库更新,需要重新编译整个程序。

动态库的优点是节省内存和磁盘空间,并且可以方便地更新,因为多个程序共享,只要更新动态库文件,所有使用该库的程序都可以受益。缺点是对环境的依赖较强,需要确保动态库在运行时能够正确地被加载和调用。

动态链接

动态链接是指在程序运行时,将程序中使用的动态库加载并链接到程序中的过程。当程序执行到需要调用动态库中的函数或访问动态库中的数据时,操作系统会根据程序中的动态链接信息,找到相应的动态库文件,并将其加载到内存中。然后,通过符号解析等操作,将程序中的函数调用和动态库中的实际函数对应起来。动态链接使得程序能够更加灵活地使用外部库,并且可以动态地更新库文件,而不需要重新编译程序。

使用过哪些动画,属性动画和 View 动画的区别在哪里,View 动画的原理(ValueAnimator 和 ObjectAnimator 的区别)?

在 Android 开发中,使用过帧动画、补间动画(View 动画)和属性动画。

属性动画和 View 动画的区别

View 动画主要包括平移、旋转、缩放和透明度变化这四种基本动画类型,它是通过对 View 的内容进行变换来实现动画效果的。例如,对一个按钮进行平移动画,按钮本身的位置属性(如left、top等)并没有真正改变,只是在屏幕上看起来位置发生了变化。并且 View 动画只能作用于 View,不能对非 View 对象进行动画。

属性动画则更加灵活,它可以对任何对象的属性进行动画操作。属性动画是通过改变对象的实际属性值来实现动画效果的。例如,对于一个自定义的对象,只要它有可修改的属性,就可以使用属性动画来改变这个属性的值,从而实现动画。而且属性动画支持的动画类型更加丰富,除了基本的平移、旋转等,还可以根据具体的属性变化来创造各种复杂的动画效果。

在动画的可扩展性方面,属性动画更好。它可以通过自定义 Evaluator 来实现对属性变化的自定义计算,比如自定义颜色变化的过渡效果等。而 View 动画在这方面相对较弱。

另外,从动画的执行结果来看,View 动画执行完后,View 会恢复到原来的状态,而属性动画会改变对象的实际属性,动画结束后对象的属性保持在动画结束时的值。

View 动画的原理(ValueAnimator 和 ObjectAnimator 的区别)

View 动画主要是基于补间动画的原理。它通过定义动画的起始状态、结束状态和中间的过渡方式来实现动画效果。

ValueAnimator 是属性动画的核心类之一。它主要用于计算动画过程中的数值变化。例如,在一个从 0 到 100 的数值变化动画中,ValueAnimator 会根据设定的时间、插值器等因素,计算出在每个时间点上的数值。ValueAnimator 本身不会直接作用于对象的属性,它只是提供了一个数值变化的计算机制。

ObjectAnimator 是 ValueAnimator 的子类,它在 ValueAnimator 的基础上,增加了对对象属性的直接操作功能。ObjectAnimator 可以直接将计算出的数值应用到对象的属性上。例如,对于一个视图的alpha(透明度)属性,ObjectAnimator 可以根据 ValueAnimator 计算出的透明度数值,直接设置到视图的alpha属性上,从而实现视图透明度的动画效果。

Fragment 的生命周期是怎样的?

Fragment 是 Android 开发中用于构建灵活的用户界面的组件,它有自己完整的生命周期。

当 Fragment 被创建时,首先会调用onAttach方法。在这个方法中,Fragment 会与它所属的 Activity 建立关联,此时可以获取到 Activity 的引用。例如,在onAttach方法中,可以将 Activity 作为参数传递给 Fragment 中的其他方法或者变量,用于后续的操作。

接着是onCreate方法,这和 Activity 的onCreate方法类似,主要用于进行一些初始化的操作。在onCreate方法中,可以进行一些数据的初始化,如设置 Fragment 的参数、初始化一些变量等。但是在这个阶段,Fragment 的视图还没有被创建。

然后是onCreateView方法,这个方法用于创建和返回 Fragment 的视图。可以通过加载布局文件或者动态创建视图的方式来返回一个视图对象。例如,使用inflater.inflate方法来加载一个 XML 布局文件,将其转换为视图对象并返回。这个视图对象将作为 Fragment 的内容显示在界面上。

在onViewCreated方法中,当视图已经被创建(通过onCreateView)后,就会调用这个方法。在这个阶段,可以对视图进行进一步的初始化操作,如查找视图中的子视图、设置视图的监听器等。例如,通过findViewById方法在 Fragment 的视图中找到按钮等子视图,并为它们设置点击监听器。

当 Fragment 所在的 Activity 可见时,Fragment 会进入onStart状态。此时 Fragment 的视图已经可见,但是还不能与用户进行交互。这和 Activity 的onStart类似,标志着 Fragment 从不可见到可见的过渡阶段。

接着是onResume方法,当 Fragment 完全可以和用户进行交互时,会调用这个方法。在这个阶段,Fragment 处于活动状态,用户可以对 Fragment 中的视图进行操作,如点击按钮、输入文字等。

当 Fragment 所在的 Activity 失去焦点但 Fragment 仍然可见时,会调用onPause方法。在这个阶段,应该暂停一些不必要的操作,如停止动画、暂停传感器监听等,以节省系统资源。

如果 Fragment 所在的 Activity 完全不可见或者 Fragment 被替换等情况发生,会调用onStop方法。在这个阶段,Fragment 的视图不再可见,可以在这个方法中释放一些与视图相关的资源。

最后,当 Fragment 被销毁时,会依次调用onDestroyView、onDestroy和onDetach方法。onDestroyView用于销毁 Fragment 的视图,onDestroy用于进行一些最后的清理工作,onDetach用于解除 Fragment 与 Activity 的关联。

四大引用的区别是什么?

在 Java(Android 开发也遵循 Java 相关机制)中存在四种引用类型,各有其特点和用途,以下是它们的区别。

强引用(Strong Reference):

这是最常见的引用方式,通过new关键字创建对象所产生的引用就是强引用,例如Object obj = new Object();里obj对Object对象的引用。只要强引用存在,对象就不会被垃圾回收器回收,哪怕内存不足了,也不会主动去回收被强引用指向的对象,而是优先抛出OutOfMemoryError异常。它保证了对象在使用期间的绝对存活,常用于正常的对象实例化和操作场景,像在类中定义成员变量并通过构造函数初始化后,只要该类的实例存在,成员变量所指向的对象就不会被回收。

软引用(Soft Reference):

软引用用于描述一些还有用但非必需的对象。在内存充足的情况下,软引用指向的对象会被保留在内存中;而当内存不够时,垃圾回收器会优先回收软引用所指向的对象来释放内存。软引用通常用于实现缓存机制,比如在一个图片加载的缓存场景中,将已加载过的图片以软引用的形式存储,当内存宽裕时,下次需要显示该图片可直接从缓存获取,若内存紧张了,就回收这些缓存图片以腾出空间。

弱引用(Weak Reference):

弱引用的强度比软引用更弱,只要垃圾回收器发现某个对象只有弱引用指向它,无论内存是否充足,都会立即回收该对象。它常用于一些需要避免对象长期驻留内存,又想在其存活期间能被获取使用的情况。例如,在一些容器类中存储元素时,使用弱引用,这样当外部没有强引用指向这些元素时,它们可以及时被回收,防止容器内存无限制增大。

虚引用(Phantom Reference):

虚引用是最弱的一种引用,它几乎无法对对象的存活起到作用。虚引用主要和引用队列配合使用,当一个对象被垃圾回收器回收时,若它只有虚引用,就会把这个虚引用添加到引用队列中,开发人员可通过检测引用队列来知晓对象已被回收。虚引用常用于在对象被回收后执行一些清理资源或者做统计等特殊操作,不过单独使用虚引用没实际意义,它不能用来获取对象实例进行常规操作。

GC 内存回收机制?Android 和 Java 中有什么区别?

Java 中的 GC 内存回收机制

Java 的 GC(垃圾回收)主要基于一些核心算法来管理内存,实现对不再使用的对象的回收。

标记 - 清除算法是基础的一种,先从根对象(像栈中的变量、静态变量等)开始,通过遍历对象图,标记出所有可达的对象,之后清除那些未被标记的对象,释放它们占用的内存空间。但这种算法会产生内存碎片问题,可能导致后续分配大对象时找不到连续的内存空间。

复制算法则是把内存划分成两块大小相等的区域,只使用其中一块来分配对象,当这块内存满了,就把存活的对象复制到另一块空闲区域,然后把原来那块内存全部清空。它的优点是不会产生内存碎片,不过会浪费一半的内存空间,常用于新生代的内存管理。

标记 - 整理算法结合了两者优点,先标记出存活对象,再把存活对象往一端移动,最后清理掉边界以外的内存,这样既解决了内存碎片问题,又避免了复制算法的空间浪费,常用于老年代的内存管理。

Java 的 GC 会根据不同的内存区域(如新生代、老年代)以及对象的存活情况等因素,自动触发不同的回收策略,确保内存合理利用,让开发者无需手动管理内存释放。

Android 中的 GC 内存回收机制及与 Java 的区别

Android 基于 Java,其 GC 机制有相似之处,但因自身特点也存在差异。

一方面,Android 设备的内存资源相对有限,所以系统对内存管理更为严格,会更频繁地触发 GC 操作,尽量避免内存耗尽影响系统的流畅运行和其他应用的使用。比如,当应用切换到后台,Android 系统会更积极地回收该应用占用的内存,促使 GC 尽快回收不再使用的对象。

另一方面,Android 开发中存在大量和系统组件紧密相关的对象,像 Activity、Service 等,它们的生命周期与用户操作和系统状态密切相关。若在开发中没正确处理好这些对象的引用,很容易出现内存泄漏,干扰 GC 的正常工作。例如,若 Activity 销毁后,仍存在强引用指向它,那 GC 就没法回收其相关资源,导致内存占用居高不下。

同时,Android 系统针对不同类型内存(堆内存、栈内存等)的管理有自身考量,堆内存中对于不同大小、类型的对象在内存布局和分配策略上有更细致的安排,以适应移动设备资源受限的情况,相比 Java,在 GC 时要综合更多的设备和应用特性来优化内存回收效果。

so 的编译过程,静态库和动态库的区别,动态链接是什么?

so 的编译过程

在 Android 开发涉及到的.so文件(共享库,也就是动态库)编译是一个较为复杂的流程。

首先是预处理阶段,对于用 C 或 C++ 等编写的源文件,预处理程序会处理诸如#include(将头文件内容包含进来)、#define(进行宏替换等操作)这些预处理指令,将源文件整理成后续编译阶段便于处理的形式。例如,当一个源文件包含了多个头文件时,预处理会把这些头文件里的声明、定义等内容融入到源文件中。

接着进入编译阶段,编译器会将预处理后的源文件转换为汇编语言文件。这个过程中,编译器依据编程语言的语法规则,对源文件的代码逻辑进行词法分析、语法分析等操作,把符合语法规范的代码转化为对应的汇编指令,例如把 C 语言中的函数调用、变量赋值等语句变成相应的汇编指令表述。

然后是汇编阶段,汇编器会接收汇编语言文件,并将其转换为目标文件(通常是.o文件)。目标文件是一种二进制文件,它包含了机器代码以及一些符号信息,这些符号信息用于后续链接阶段进行符号的解析和重定位等操作,比如某个函数在目标文件中的符号表示,会在链接时和其他相关文件中的同名函数进行关联。

最后是链接阶段,对于.so文件这种动态库来说,链接器会把多个目标文件以及一些必要的系统库等文件链接在一起,生成最终的共享库文件。在链接时,要处理符号的重定位,也就是确定函数、变量等符号在最终库文件中的实际地址,使得库中的函数和变量能够在运行时被正确调用和访问。

静态库和动态库的区别

存储与使用方式: 静态库在程序链接阶段,会把库文件中的代码和数据全部复制到最终的可执行文件当中。比如有一个静态库包含了几个函数,一旦某个程序链接了这个静态库,那这些函数的代码就实实在在地嵌入到了程序的可执行文件里,后续程序运行时就不再依赖这个静态库文件本身了。而动态库则不同,它的代码和数据不会被复制到可执行文件里,是在程序运行过程中,当需要调用库中的函数或者访问库中的数据时,才会去加载动态库,多个程序可以共享同一个动态库,节省了内存和磁盘空间。

可移植性与更新便利性: 静态库的优点在于可移植性强,因为可执行文件已经包含了所有需要的代码,拿到其他环境下基本都能直接运行,不受外部库文件存在与否的影响。然而它的缺点是一旦静态库有更新,哪怕只是修改了其中一个小函数,都需要重新编译整个程序来更新应用。动态库的优势在于节省资源以及更新方便,多个应用可以共用一个动态库,只要更新了动态库文件,所有使用它的应用都能受益,不过它对运行环境依赖较强,要是运行时找不到对应的动态库或者动态库版本不匹配等情况出现,程序就可能无法正常运行。

内存占用情况: 静态库会使可执行文件的体积增大,因为它把相关代码都复制进去了,而动态库本身不会增加可执行文件大小,多个程序共用还能在内存中共享,所以在大规模应用场景下,动态库在内存利用方面更有优势。

动态链接

动态链接指的是在程序运行时,将程序中使用的动态库加载并链接到程序中的过程。当程序执行到需要调用动态库中的函数或者访问动态库中的数据时,操作系统会依据程序中记录的动态链接信息,去查找对应的动态库文件,找到后将其加载到内存里。之后通过符号解析等操作,把程序里对函数的调用等和动态库中实际的函数对应起来,使得程序能够顺利调用库中的功能。这种方式让程序可以更灵活地运用外部库,并且方便动态更新库文件,不用重新编译整个程序,只要替换相应的动态库就行,提升了软件开发和维护的效率以及资源利用的合理性。

使用过哪些动画,属性动画和 View 动画的区别在哪里,View 动画的原理(ValueAnimator 和 ObjectAnimator 的区别)?

使用过的动画

在 Android 开发中,常用的动画有帧动画、View 动画(补间动画)以及属性动画。

帧动画就是通过连续播放一系列预先定义好的图片帧来形成动画效果,类似播放电影胶片一样,常用于实现简单的、规律性的动态效果,比如一个小火苗的闪烁动画,可以通过一组不同状态的火苗图片按顺序播放来实现。

View 动画(补间动画)包含了平移、旋转、缩放、透明度变化这几种基本类型,通过定义动画的起始和结束状态以及过渡方式,就能让 View 呈现出相应的动画效果,常用于一些简单的 UI 元素的动画展示场景,像按钮的淡入淡出、图片的旋转等。

属性动画则更加灵活强大,它能够对任何对象的属性进行动画操作,只要对象有可修改的属性,就可以通过属性动画来改变属性值以实现动画,可用于创造各种复杂、自定义的动画效果,比如自定义控件的属性随着时间动态变化等情况。

属性动画和 View 动画的区别

作用对象范围: View 动画主要是针对 View 进行操作,通过对 View 的内容进行变换来展现动画效果,例如对一个 TextView 做平移动画,其实是在屏幕上改变其显示位置的视觉效果,而 TextView 本身的布局属性等并没有真正改变。属性动画则可以对任何对象的属性进行动画,不限于 View,只要对象的属性有对应的get和set方法(或者符合一定的属性访问规则),就能实现动画效果,像对一个自定义的实体类对象的某个自定义属性进行动画改变是可行的。

动画效果持久性: View 动画在执行完后,View 会恢复到原来的状态,也就是动画只是视觉上的呈现,没有改变 View 的实际属性值。而属性动画会实实在在地改变对象的实际属性,动画结束后对象的属性就停留在动画结束时的值,比如通过属性动画改变了一个按钮的透明度,动画结束后按钮的透明度就保持在最后设置的值上。

动画扩展性: 属性动画的扩展性更强,它可以通过自定义 Evaluator(估值器)来实现对属性变化的自定义计算。例如,想要实现一种特殊的颜色渐变动画,从一种颜色逐渐过渡到另一种颜色,就可以自定义颜色的 Evaluator 来精准控制颜色变化过程,而 View 动画在这方面的自定义能力相对较弱,基本只能按照既定的几种类型和参数来实现动画。

View 动画的原理(ValueAnimator 和 ObjectAnimator 的区别)

View 动画基于补间动画原理,通过设定动画的起始状态、结束状态以及中间的过渡方式来达成动画效果。

ValueAnimator 是属性动画的核心类之一,它重点在于计算动画过程中的数值变化。比如设定一个动画是从数值 0 变化到 100,持续时间是 5 秒,ValueAnimator 就会根据设定的时间、插值器(用来控制动画变化速率的组件,如匀速、加速等)等要素,计算出在每个时间点上具体的数值是多少。但 ValueAnimator 本身并不会直接把这些数值应用到对象的属性上,它只是单纯提供了一个数值变化的计算机制。

ObjectAnimator 是 ValueAnimator 的子类,它在 ValueAnimator 的基础上,增加了对对象属性的直接操作功能。以对一个视图的alpha(透明度)属性做动画为例,ObjectAnimator 会先利用 ValueAnimator 计算出每个时间点的透明度数值,然后直接把这些数值设置到视图的alpha属性上,从而让视图呈现出透明度变化的动画效果,也就是 ObjectAnimator 把数值变化和属性改变进行了关联,实现了对具体对象属性的动画操作。

Fragment 的生命周期是怎样的?

Fragment 作为 Android 中构建灵活界面的重要组件,有着完整且独特的生命周期过程。

当 Fragment 开始创建时,首先会调用onAttach方法,在这个阶段,Fragment 与它所属的 Activity 建立起关联,它可以获取到 Activity 的引用,便于后续在 Fragment 中与 Activity 进行交互,比如获取 Activity 中的资源或者调用 Activity 的方法等。

接着进入onCreate方法,这里的操作和 Activity 的onCreate类似,主要用于进行 Fragment 内部的一些初始化工作,像初始化成员变量、设置 Fragment 相关的参数等,不过要注意此时 Fragment 的视图还没有被创建。

之后是onCreateView方法,这个方法的核心作用是创建并返回 Fragment 的视图。可以通过加载布局文件的方式,利用 LayoutInflater 将 XML 布局文件转换为视图对象返回,也可以通过代码动态地创建视图。例如,inflater.inflate(R.layout.fragment_layout, container, false);这样的代码就是加载指定的布局文件并返回视图,这个视图后续就会作为 Fragment 展示在界面上。

在onViewCreated方法中,因为onCreateView已经创建好了视图,所以此时会调用该方法,在这里可以对视图进行进一步的细化操作,比如通过findViewById方法查找视图中的子视图,然后为这些子视图设置监听器,或者进行一些视图属性的初始设置等。

当 Fragment 所在的 Activity 变得可见时,Fragment 就会进入onStart状态,此时 Fragment 的视图已经可以被看到了,不过还不能和用户进行交互,这是从不可见到可见的一个过渡阶段,类似 Activity 的onStart状态。

随着 Activity 完全准备好,Fragment 也会进入onResume状态,在这个阶段,Fragment 就可以和用户进行交互了,用户能够对 Fragment 中的视图进行各种操作,比如点击按钮、输入文字等。

如果 Fragment 所在的 Activity 失去焦点了,但是 Fragment 依然可见,就会调用onPause方法,这时应该暂停一些不必要的操作,例如停止正在播放的动画、暂停传感器的监听等,以此来节省系统资源,避免影响系统整体性能。

当 Fragment 所在的 Activity 变得完全不可见,或者 Fragment 要被替换等情况发生时,会调用onStop方法,此时 Fragment 的视图不再可见,可以在这个方法里释放一些和视图相关的资源,比如关闭视图中正在使用的网络连接等。

最后,当 Fragment 要被销毁时,会依次经过onDestroyView、onDestroy和onDetach这几个方法。onDestroyView用于销毁 Fragment 已经创建的视图,释放视图相关的资源;onDestroy则是进行一些最后的清理工作,比如释放 Fragment 中其他的一些资源;onDetach就是解除 Fragment 与所属 Activity 的关联,标志着 Fragment 整个生命周期的结束。

Service 的生命周期是怎样的?

Service 是 Android 中用于在后台执行长时间运行操作的组件,它有着独特的生命周期流程。

当通过startService方法启动一个 Service 时,首先会调用onCreate方法。在onCreate方法中,主要进行一些初始化的操作,例如初始化一些变量、创建与其他组件(如数据库、网络等)连接的资源等,这一步和 Activity 的onCreate类似,不过 Service 的onCreate只会在服务首次创建时执行一次。

接着会调用onStartCommand方法,这个方法非常关键,每次通过startService启动服务或者有新的意图(Intent)传递过来启动服务时,都会触发该方法。在onStartCommand方法中,可以根据传入的 Intent 获取相关的参数信息,然后执行具体的后台任务,比如启动一个线程去持续下载文件、定时更新数据等,而且该方法需要返回一个整型值,用于指示系统在服务因异常终止等情况后如何处理,常见的返回值有START_STICKY(服务被异常终止后会尝试重新启动,但是不会重新传递上一次的 Intent)、START_NOT_STICKY(服务被异常终止后不会自动重新启动)、START_REDELIVER_INTENT(服务被异常终止后会重新启动并且重新传递上一次的 Intent)等。

在 Service 执行任务的过程中,如果调用了stopService方法或者服务自身执行完任务后通过stopSelf方法主动停止服务,就会进入onDestroy方法,在这个方法中,要进行一些资源清理工作,比如关闭之前打开的文件流、断开网络连接、释放内存等,确保服务占用的资源都能合理地被回收。

如果是通过bindService方法绑定启动一个 Service,首先同样会调用onCreate方法进行初始化,然后会调用onBind方法,这个方法返回一个IBinder对象,用于和绑定的组件(通常是 Activity)进行通信,实现跨组件的数据交互和方法调用等操作,比如 Activity 可以通过获取的IBinder对象调用 Service 中的方法获取后台运行的数据等。

当所有绑定的组件都解除绑定(通过unbindService方法)后,服务会先判断是否还有通过startService启动的情况,如果没有,就会进入onDestroy方法进行资源清理并销毁服务;如果还有startService启动的情况,服务会继续运行,等待后续的停止操作。

另外,无论服务是通过哪种方式启动的,在系统内存不足等特殊情况下,服务也可能会被强制终止,这时如果服务是通过startService启动且设置了合适的onStartCommand返回值(如START_STICKY),就可能会被重新启动,继续执行之前的任务。

如果 Service 在运行过程中被系统回收,并且它返回的onStartCommand值是START_STICKY,系统会重新创建 Service 实例。此时,会再次调用onCreate方法进行初始化,随后调用onStartCommand,不过此时传入的 Intent 可能为空。

当 Service 在前台运行时,也就是调用startForeground方法后,它的优先级会提高,系统会尽量避免回收它。在这种情况下,需要为 Service 提供一个通知(Notification),当 Service 在前台运行时,这个通知会一直显示在状态栏。从生命周期角度看,这并不改变 Service 原有的onCreate、onStartCommand等核心方法的调用逻辑,只是增加了一种运行状态,让用户能够知晓服务正在前台运行。

而且,Service 在运行过程中也可以和其他组件进行交互来更新自己的运行状态。例如,它可以通过广播(Broadcast)告知其他组件自己的任务进度或者状态变化。同时,Service 也可以接收来自其他组件的广播,根据广播内容来调整自己的运行任务,这也进一步体现了 Service 在 Android 组件间通信和协作中的重要作用,其生命周期的各个阶段都有可能受到这些交互的影响。

BroadCastReceiver 的源码看过么?

有看过 BroadCastReceiver 的部分源码,它在 Android 系统中扮演着很重要的角色,用于接收系统或者应用发出的广播消息并做出响应。

从注册方面来看,源码中展示了通过代码注册(使用registerReceiver方法)和在 AndroidManifest.xml 文件中静态注册的不同实现方式。静态注册时,系统解析清单文件时会将相关的广播接收器信息进行提取和存储,以便后续在对应广播发出时能找到并触发它;而代码注册则更灵活,可以在运行时根据具体需求决定何时注册以及注册哪些广播类型等。

在接收广播的过程中,源码里能看到当广播发出后,系统会遍历已注册的广播接收器,判断其注册的广播类型是否匹配,匹配的话就会开始调用广播接收器的onReceive方法。在onReceive方法里,就可以进行相应的业务逻辑处理,比如收到网络连接变化广播后,可以在这个方法里重新获取网络相关信息或者调整应用的网络使用策略等。

并且,广播接收器的生命周期也能从源码中清晰体现,它的生命周期相对短暂,onReceive方法执行完后对象就可能被回收,所以在这个方法里不能进行耗时过长的操作,否则容易引发 ANR 等问题,源码中这样的设计也是为了确保广播接收和处理的高效性以及整个系统的流畅运行。

Message.obtain () 有什么好处,为什么不使用 new Message?

Message.obtain () 有着显著的优势,相比直接使用 new Message (),它主要是从对象复用的角度来优化内存使用,避免了频繁的对象创建和回收带来的性能损耗以及内存碎片等问题,进而防止多次触发 GC。

当使用 new Message () 时,每一次创建都会在堆内存中分配一块新的内存空间来构建一个全新的 Message 对象。在一些高并发或者频繁发送消息的场景下,比如在一个不断有网络数据返回需要通过 Handler 传递消息到主线程更新 UI 的应用中,如果都用 new Message (),会短时间内产生大量的 Message 对象,这些对象在使用完后等待垃圾回收器回收,会增加 GC 的负担。

而 Message.obtain () 方法则不一样,它实际上是从一个消息池中获取已经存在的 Message 对象进行复用。这个消息池会预先存储一些闲置的 Message 对象,当调用 obtain () 时,就从池中取出一个可用的,使用完毕后,通过调用 Message 的recycle方法又可以将其放回消息池,以待后续再次复用。这样就避免了频繁地创建和销毁 Message 对象,减少了内存分配和回收的操作次数,让内存使用更加高效,尤其是在消息量较大的场景下,能有效降低内存抖动情况,使应用运行更加流畅,也避免了因过多对象等待回收而频繁触发 GC,从而影响整体性能。

算法题:100000 个数字中,对前 10000 小的数字进行排序

对于从 100000 个数字中找出前 10000 小的数字并排序这个问题,可以采用多种算法思路来解决,这里介绍一种基于堆排序的优化解法,即构建大小为 10000 的大顶堆来实现。

首先,从给定的 100000 个数字中取出前 10000 个数字,构建一个大顶堆。构建大顶堆的过程就是从最后一个非叶子节点开始,依次对每个节点进行调整,使得父节点的值大于子节点的值,保证堆的性质。对于节点i,其左子节点是2 * i + 1,右子节点是2 * i + 2,通过不断比较和交换节点值来维护大顶堆结构。

接着,遍历剩下的 90000 个数字,当遇到一个比大顶堆堆顶元素小的数字时,就将堆顶元素替换为这个较小的数字,然后重新调整大顶堆,使其依然满足大顶堆的性质,这个过程其实就是不断把更小的数字 “筛选” 进堆里。

经过这样一轮遍历后,大顶堆里存储的就是 10000 个最小的数字了。最后,对这个大顶堆进行排序,由于大顶堆的特点是堆顶元素最大,我们可以采用依次取出堆顶元素,然后调整堆的方式来进行排序,每取出一个堆顶元素后,将堆的最后一个元素放到堆顶,再进行调整,如此反复,就可以得到从小到大排序后的前 10000 个数字。

这种方法相较于直接对 100000 个数字进行排序然后取前 10000 个的做法,在时间复杂度上有很大优化,它不需要对所有数字都进行完整的排序操作,利用堆的特性快速筛选出了目标数字,整体时间复杂度大约为 O (nlogk),这里 n 是数字总数 100000,k 是要选取的最小数字个数 10000,大大提高了效率,尤其适用于处理大规模数据中选取部分较小值并排序的情况。

知道哪些设计模式?

在软件开发中了解多种常用的设计模式,比如创建型模式中的单例模式、工厂模式、建造者模式;结构型模式里的代理模式、装饰器模式、桥接模式;行为型模式包含观察者模式、策略模式、模板方法模式等。

单例模式主要是确保一个类只有一个实例,并提供一个全局访问点来获取这个实例,常用于一些需要全局唯一对象的场景,像在应用中管理配置信息、日志记录等,避免创建多个实例造成资源浪费或者数据不一致等问题。

工厂模式则是将对象的创建和使用分离,通过一个工厂类来负责创建不同类型的对象,根据传入的参数或者配置等决定创建具体哪种对象,这样使得代码的可维护性和扩展性更好,例如在游戏开发中,通过工厂模式可以创建不同类型的游戏角色,后续如果要新增角色类型,只需要在工厂类里修改创建逻辑即可,不影响使用角色的其他代码。

代理模式是为其他对象提供一种代理以控制对这个对象的访问,比如在网络请求中,可以有一个代理类先对请求进行预处理,如验证权限、缓存数据等,再去真正调用目标对象的请求方法,增强了目标对象的安全性和性能等方面的控制。

装饰器模式能够动态地给一个对象添加一些额外的职责,就像给一杯咖啡添加不同的配料(奶、糖等)来改变其口味一样,在不改变原有对象结构的基础上拓展其功能,常用于对已有类的功能扩展场景。

桥接模式主要是将抽象部分与它的实现部分分离,使它们可以独立地变化,比如在图形绘制系统中,形状(圆形、矩形等)是抽象部分,而颜色可以是实现部分,通过桥接模式可以让形状和颜色自由组合,方便系统的扩展和维护。

观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象,当主题对象状态发生变化时,所有观察者都会收到通知并做出相应的更新,常用于消息推送、事件监听等场景,像在 UI 界面中,多个视图组件可以作为观察者,监听数据模型这个主题对象的变化,数据变化时及时更新显示内容。

策略模式是定义了一系列的算法,将每个算法封装起来,并使它们之间可以相互替换,比如在电商应用中,不同的优惠策略(满减、折扣、赠品等)可以看作不同的算法,根据不同的条件选择不同的策略来计算最终的价格,这样便于算法的切换和扩展,提高了代码的灵活性。

模板方法模式在一个方法中定义了一个算法的骨架,而将一些具体的步骤延迟到子类中,子类可以在不改变算法整体结构的基础上,根据自身需求实现具体的步骤,就像在制作不同种类的饮品时,都有烧水、放原料、搅拌等基本步骤,但是具体放什么原料可以由不同的子类(不同饮品)来决定,它便于代码的复用和扩展。

说一下建造者模式的核心,并写一下(代码题)

建造者模式的核心在于将一个复杂对象的构建和它的表示分离,使得同样的构建过程可以创建不同的表示。也就是把对象的创建过程分解成多个步骤,通过不同的组合方式来构建出不同的最终对象,并且将这些步骤封装在一个建造者类中,由指挥者类来统一协调建造的流程。

以下是一个简单的建造者模式示例代码,以构建电脑为例:

首先是电脑产品类,它有多个部件属性:

class Computer {
    private String cpu;
    private String memory;
    private String hardDisk;
 
    public void setCpu(String cpu) {
        this.cpu = cpu;
    }
 
    public void setMemory(String memory) {
        this.memory = memory;
    }
 
    public void setHardDisk(String hardDisk) {
        this.hardDisk = hardDisk;
    }
 
    @Override
    public String toString() {
        return "Computer{" +
                "cpu='" + cpu + '\'' +
                "memory='" + memory + '\'' +
                "hardDisk='" + hardDisk + '\'' +
                '}';
    }
}

然后是建造者类,它定义了构建电脑各个部件的方法:

class ComputerBuilder {
    private Computer computer = new Computer();
 
    public ComputerBuilder buildCpu(String cpu) {
        computer.setCpu(cpu);
        return this;
    }
 
    public ComputerBuilder buildMemory(String memory) {
        computer.setMemory(memory);
        return this;
    }
 
    public ComputerBuilder buildHardDisk(String hardDisk) {
        computer.setHardDisk(hardDisk);
        return this;
    }
 
    public Computer build() {
        return computer;
    }
}

最后是指挥者类,它负责按照一定顺序调用建造者的方法来构建电脑:

class Director {
    public static Computer constructComputer(ComputerBuilder builder) {
        return builder.buildCpu("Intel i7")
               .buildMemory("16GB")
               .buildHardDisk("1TB SSD")
               .build();
    }
}

在使用时,可以这样:

Computer computer = Director.constructComputer(new ComputerBuilder());
System.out.println(computer);

通过这样的方式,我们可以灵活地改变构建电脑的各个部件配置,比如可以轻松构建出不同配置的电脑,只需要在指挥者调用建造者方法时传入不同的参数,就实现了复杂对象构建过程的灵活控制和不同配置对象的创建,这就是建造者模式的核心应用场景体现。

写一下单例并讲一下,经典双重校验锁

以下是几种常见的单例模式实现方式以及对经典双重校验锁的讲解。

  1. 饿汉式单例:

public class Singleton {
    // 直接创建实例,在类加载时就完成实例化
    private static final Singleton instance = new Singleton();
 
    // 私有构造函数,防止外部通过new创建实例
    private Singleton() {}
 
    // 提供公共的静态方法获取实例
    public static Singleton getInstance() {
        return instance;
    }
}

这种方式的优点是实现简单,线程安全,因为类加载过程是由 JVM 保证线程安全的,在多线程环境下也能保证只有一个实例被创建。缺点是不管是否会用到这个实例,在类加载时就会创建实例,可能会造成资源浪费,比如实例创建很耗时或者占用大量资源,而实际应用中可能很长时间都不会用到它。

  1. 懒汉式单例(非线程安全):

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

这种方式是在第一次调用getInstance方法时才创建实例,实现了延迟加载,一定程度上避免了资源浪费。但它是非线程安全的,在多线程环境下,可能会有多个线程同时判断instance为null,然后都去创建实例,就破坏了单例的特性。

  1. 懒汉式单例(加锁方式,线程安全但效率低):

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

通过给getInstance方法添加synchronized关键字实现了线程安全,保证了在多线程环境下只有一个实例会被创建。但缺点是每次调用getInstance方法都会加锁,即使实例已经创建了,也会加锁,会导致性能开销较大,影响效率,尤其是在高并发情况下。

  1. 经典双重校验锁(DCL,线程安全且高效):

public class Singleton {
    // 使用volatile关键字修饰,保证可见性以及禁止指令重排序
    private static volatile Singleton instance;
 
    private Singleton() {}
 
    public static Singleton getInstance() {
        if (instance == null) {
            // 第一次校验,减少不必要的加锁操作,提高效率
            synchronized (Singleton.class) {
                if (instance == null) {
                    // 第二次校验,确保在加锁期间没有其他线程创建实例
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

在经典双重校验锁的单例实现中,首先instance变量使用volatile关键字修饰,这很关键,它有两个作用,一是保证了不同线程对instance变量的可见性,即一个线程修改了instance的值,其他线程能立即看到;二是禁止了指令重排序,因为instance = new Singleton();这个操作实际上包含了分配内存、初始化对象、将对象引用赋值给instance这几个步骤,在没有volatile时,可能会出现指令重排序,导致其他线程获取到未完全初始化的实例。

在getInstance方法中,第一次if (instance == null)校验是在未加锁的情况下进行的,目的是减少不必要的加锁操作,因为如果实例已经创建了,就不需要进入加锁代码块了,提高了效率。然后进入加锁代码块后,再次进行if (instance == null)校验,这是为了确保在加锁期间没有其他线程抢先创建了实例,通过这双重校验,既保证了线程安全,又在实例已经创建的情况下避免了每次获取实例都加锁的性能损耗,所以在多线程环境下是一种高效且安全的单例实现方式。

最近更新:: 2025/10/22 15:36
Contributors: luokaiwen