猫眼 面试
Android 性能优化,非静态内部类编译的字节码有几份?
在 Java(包括 Android 开发所用的 Java 环境)中,非静态内部类在编译后会生成独立的字节码文件。对于每一个非静态内部类,编译器都会生成一份独立的字节码。
原因在于非静态内部类持有外部类的引用,这使得它和外部类紧密耦合。编译器需要为其生成单独的字节码来定义它的结构、方法、属性等。例如,假设有一个外部类 Outer 和一个非静态内部类 Inner。当编译时,会有 Outer.class 和 Outer$Inner.class 两个字节码文件生成。
从性能优化角度来看,字节码文件过多可能会增加应用的安装包大小。而且,非静态内部类的实例化过程相对复杂一些,因为要关联外部类的实例。在内存使用方面,如果大量创建非静态内部类的实例,并且外部类的实例也被长时间持有,可能会导致内存泄漏风险。例如,在 Activity 中定义了非静态内部类作为异步任务的回调,当异步任务的生命周期长于 Activity 时,就可能导致 Activity 无法被垃圾回收,从而占用内存。
另外,在加载类的过程中,每个字节码文件都需要一定的时间和资源来加载和初始化。过多的非静态内部类字节码可能会影响应用的启动速度和运行时性能。在优化时,可以考虑将非静态内部类改为静态内部类(如果不需要访问外部类的非静态成员),这样可以减少内存耦合,并且在字节码层面上,静态内部类不会持有外部类的实例引用,在某些情况下能够更好地进行内存管理和性能优化。
RelativeLayout 和 LinearLayout 性能比较,还有约束布局,哪一个性能好?
LinearLayout 性能特点
- LinearLayout 是一种线性布局,它按照水平或者垂直方向排列子视图。在布局过程中,它的计算相对简单。如果是水平方向排列,它主要计算子视图的宽度总和以及它们之间的间距,垂直方向同理。例如,当我们有一系列按钮在 LinearLayout 中垂直排列时,它只需要依次计算每个按钮的高度、间距,然后累加得到总高度,这个过程的计算量相对较小。
- 但是,如果布局嵌套层级过深,LinearLayout 可能会导致性能下降。因为每次嵌套,都需要重新计算内部 LinearLayout 的尺寸和位置,并且传递给父布局,过多的嵌套会增加计算量和布局传递的开销。
RelativeLayout 性能特点
- RelativeLayout 是相对布局,它通过相对位置来排列子视图。它的灵活性很高,可以实现复杂的布局效果,如某个视图在另一个视图的下方并且和第三个视图左对齐等。然而,这种灵活性是有代价的。在布局过程中,它需要解析每个视图的相对位置规则,计算量相对较大。
- 当视图数量较多时,解析这些相对位置的时间复杂度会增加。例如,在一个 RelativeLayout 中有 10 个视图,每个视图都有多个相对位置约束,那么在布局时就需要解析这些约束来确定每个视图的最终位置,这可能会导致布局过程变慢。
ConstraintLayout 性能特点
- ConstraintLayout 是 Google 推荐的一种布局,它结合了 LinearLayout 和 RelativeLayout 的优点。它通过约束条件来定义视图的位置,能够实现非常复杂的布局效果,而且在性能上表现良好。
- 在布局性能方面,ConstraintLayout 通过优化的布局算法,能够在处理复杂布局时比 RelativeLayout 更高效。它在构建视图树和计算布局时,采用了更合理的方式来减少计算量。例如,在设计响应式布局时,ConstraintLayout 可以通过设置不同的约束条件来适应不同的屏幕尺寸,而不会像 RelativeLayout 那样因为复杂的相对位置规则而导致性能下降严重。
- 与 LinearLayout 相比,ConstraintLayout 在处理复杂的布局结构(非简单的线性排列)时,能够避免 LinearLayout 嵌套带来的性能问题,同时又能实现类似 RelativeLayout 的复杂布局效果。
总体而言,在简单的线性排列布局场景下,LinearLayout 性能较好;在复杂布局且需要相对位置的情况下,ConstraintLayout 性能优于 RelativeLayout,是比较好的选择。
Android 用过哪些布局?
在 Android 开发中,有多种布局可以使用。
LinearLayout(线性布局)
- 如前面提到的,它以线性方式排列子视图。可以通过设置属性来控制排列方向是水平(android:orientation="horizontal")还是垂直(android:orientation="vertical")。例如,在一个简单的用户登录界面中,可以用垂直方向的 LinearLayout 来放置用户名输入框、密码输入框和登录按钮。这样可以很方便地将子视图按照顺序排列,并且可以通过设置权重(android:layout_weight)来分配子视图在剩余空间中的占比。比如,在一个水平方向的 LinearLayout 中有两个按钮,通过设置不同的权重,可以让它们按照不同的比例分配宽度。
RelativeLayout(相对布局)
- 用于通过相对位置来排列子视图。比如可以设置一个视图在另一个视图的上方、下方、左边或者右边等。在制作一个带有标题栏、内容区和底部导航栏的界面时,可以用 RelativeLayout。将标题栏设置在顶部,底部导航栏设置在底部,内容区在中间,通过相对位置来精确布局各个部分。
FrameLayout(帧布局)
- 所有的子视图都堆叠在左上角。它常用于需要覆盖显示的场景,比如在一个地图应用中,当需要在地图上显示一个自定义的位置标记(如一个小图标),可以使用 FrameLayout 将图标覆盖在地图视图上。或者在一些需要显示加载动画的场景,加载动画的视图可以放在 FrameLayout 中覆盖在内容视图上,当内容加载完成后再隐藏动画视图。
TableLayout(表格布局)
- 以表格形式排列子视图。它由 TableRow 组成,每个 TableRow 代表一行,里面的子视图代表表格的列。例如,在展示用户信息列表时,像姓名、年龄、性别等信息可以以表格的形式呈现,使用 TableLayout 可以很方便地实现这种布局。
GridLayout(网格布局)
- 将屏幕划分为网格,子视图可以放置在这些网格单元中。它比 TableLayout 更灵活,在布局一些九宫格或者相册图片展示等场景非常有用。例如,一个简单的图片浏览器应用,用 GridLayout 来展示多张图片,每个图片视图占据一个网格单元,并且可以通过设置属性来控制每个单元的大小和排列方式。
ConstraintLayout(约束布局)
- 它通过约束条件来确定子视图的位置。可以很方便地创建复杂的布局并且在不同屏幕尺寸下保持良好的适应性。在开发一个响应式的新闻阅读应用界面时,使用 ConstraintLayout 可以很好地让标题、正文、图片等元素根据屏幕大小和方向自动调整位置和大小,比如在竖屏时图片在文字上方,横屏时图片在文字旁边等复杂布局场景。
View 的绘制会经过哪些(onMeasure、onLayout、onDraw)?
onMeasure 阶段
- 这个阶段主要是测量 View 的大小。View 自身会根据父视图传递过来的 MeasureSpec 参数来确定自己的大小。MeasureSpec 包含了测量模式(UNSPECIFIED、EXACTLY、AT_MOST)和大小值。对于不同的 View 类型,测量的方式有所不同。
- 例如,对于一个简单的 TextView,它会根据文本内容、字体大小、填充(padding)等因素来计算自己所需的宽度和高度。如果是一个自定义 View,开发人员需要重写 onMeasure 方法来准确地计算 View 的大小。在这个过程中,还可能涉及到对子视图的测量。比如在一个 ViewGroup(如 LinearLayout)中,它会遍历所有子视图,调用子视图的 measure 方法,传递合适的 MeasureSpec 参数,让子视图进行自我测量。
- 而且,在测量过程中,还需要考虑到父视图的约束。例如,父视图可能限制了子视图的最大宽度或者高度,这时候子视图就需要根据这些约束来调整自己的测量结果。
onLayout 阶段
- 当 View 的大小测量完成后,就进入 onLayout 阶段。这个阶段主要是确定 View 在父视图中的位置。对于 ViewGroup 类型的 View,这个过程尤为重要。ViewGroup 需要根据自己的布局规则来安排子视图的位置。
- 例如,在 LinearLayout 中,如果是水平方向布局,它会根据子视图的测量宽度和间距,从左到右依次确定子视图的位置;如果是垂直方向,则从上到下确定位置。对于 RelativeLayout,它会根据之前在布局文件或者代码中设置的相对位置规则,来放置子视图。在自定义 ViewGroup 时,开发人员需要重写 onLayout 方法来实现自己的布局逻辑。
onDraw 阶段
- 这是最后一个阶段,主要是将 View 的内容绘制到屏幕上。在这个阶段,View 会使用 Canvas 对象来进行绘制操作。对于一个 TextView,它会在这个阶段绘制文本内容、背景颜色等。对于自定义 View,如果要绘制图形,就可以在 onDraw 方法中使用 Canvas 的各种绘制方法,如画线(drawLine)、画矩形(drawRect)、画圆(drawCircle)等。
- 同时,在 onDraw 阶段还需要考虑绘制的顺序和层次。例如,在 FrameLayout 中,由于所有子视图堆叠在一起,后绘制的视图可能会覆盖先绘制的视图,所以需要合理安排绘制顺序。而且,在绘制过程中还可能涉及到一些性能优化,比如减少不必要的重绘,通过设置 View 的可见性、缓存绘制结果等方式来提高绘制效率。
让 View 重新绘制的方法,重绘时 onMeasure 会调用几次?onMeasure 会执行几次?
让 View 重新绘制的方法
- 可以调用 View 的 invalidate () 方法来请求重新绘制。当调用这个方法时,View 会被标记为需要重绘,系统会在下一次绘制循环到来时重新绘制这个 View。还有 invalidate (int l, int t, int r, int b) 方法,它可以指定一个矩形区域来进行局部重绘,这样可以在只需要更新部分视图内容时提高效率。
- 另外,postInvalidate () 方法也可以用于请求重绘,它主要用于在非 UI 线程中请求重绘。例如,在一个后台线程中获取到新的数据后,需要更新 UI,就可以使用 postInvalidate () 方法来请求重绘相关的 View。
重绘时 onMeasure 的调用次数
- 一般情况下,当调用 invalidate () 方法进行重绘时,如果 View 的大小没有改变,onMeasure 不会被调用。因为重绘主要关注的是视图的内容绘制,而不是大小的重新测量。
- 但是,如果在重绘过程中,View 的布局参数(如宽度、高度的设置)发生了变化,或者父视图的大小发生了变化,导致 View 的大小需要重新确定,那么 onMeasure 就会被调用。而且,如果是使用 invalidate (int l, int t, int r, int b) 进行局部重绘,并且局部重绘区域的大小变化影响到了 View 的整体尺寸测量,onMeasure 也会被调用。
- 在复杂的布局结构中,比如一个 ViewGroup 中包含多个子 View,当其中一个子 View 的大小变化引发了父 ViewGroup 的重新布局,那么父 ViewGroup 及其所有受影响的子 View 的 onMeasure 方法都可能会被调用。例如,在一个 LinearLayout 中,当一个子 View 的宽度设置为 match_parent,而它的内容发生变化导致宽度需要重新测量,那么 LinearLayout 会重新测量所有子 View 的大小,这时候每个子 View 的 onMeasure 方法都会被调用。
onMeasure 会执行几次
- 在 View 的生命周期中,onMeasure 方法至少会执行一次。在 View 第一次被添加到布局中时,会执行 onMeasure 来确定其初始大小。
- 当屏幕方向发生改变、设备配置变化(如键盘弹出或收起)或者动态修改了 View 的布局参数等情况时,onMeasure 也会被执行。例如,在一个 Activity 中,当用户旋转屏幕,布局会重新调整,所有相关 View 的 onMeasure 方法都会被调用来重新确定大小。而且,在一些动画效果中,如果涉及到 View 的大小变化,每次大小变化的关键帧处,onMeasure 可能也会被调用,以确保 View 的大小能够准确地适应动画过程中的变化。
ConstraintLayout 经常用到的属性,guaidline 为什么不会在布局上显示?
ConstraintLayout 常用属性
- layout_constraintStart_toStartOf 和 layout_constraintEnd_toEndOf 等位置约束属性:这些属性用于精确地控制视图之间的相对位置。例如,layout_constraintStart_toStartOf 可以将一个视图的起始边界(通常是左边)与另一个视图的起始边界对齐。假设我们有两个按钮,要让第二个按钮的左边和第一个按钮的左边对齐,就可以使用这个属性。这在创建复杂的表单或者列表布局时非常有用,可以确保元素之间的整齐排列。
- layout_constraintTop_toTopOf 和 layout_constraintBottom_toBottomOf 属性:与上述水平方向的约束类似,这两个属性用于垂直方向的位置对齐。比如在一个带有标题和内容区域的界面中,使用 layout_constraintTop_toTopOf 可以将内容区域的顶部与标题区域的底部对齐,实现紧密的布局衔接。
- layout_constraintWidth_percent 和 layout_constraintHeight_percent 属性:用于按百分比设置视图的宽度和高度。在创建响应式布局时,这两个属性可以让视图根据父视图的大小按比例调整自己的尺寸。例如,在一个适配不同屏幕尺寸的图片展示布局中,使用 layout_constraintWidth_percent 可以让图片视图始终占据父视图宽度的一定比例,保证在不同设备上的显示效果。
- layout_constraintDimensionRatio 属性:用于设置视图的宽高比。比如在一个需要保持正方形形状的图标布局中,通过设置这个属性,可以确保视图的宽度和高度保持固定的比例,无论视图的大小如何变化。
关于 Guideline 不显示的原因
- Guideline 是一种辅助布局的工具,它本质上不是一个用于显示内容的视图。它的主要作用是提供约束参考。例如,我们可以创建一个垂直的 Guideline,将它定位在屏幕宽度的一定比例位置,然后将其他视图的约束参考这个 Guideline。这样可以方便地实现按比例划分屏幕空间的布局效果。它就像建筑施工中的参考线,只是帮助我们确定其他实体(视图)的位置,而自身不需要显示出来。
Activity 加载 Fragment 的方式,add 一个 Fragment 的时候已经有一个 Fragment 的话,对之前的 Fragment 的生命周期的影响。
Activity 加载 Fragment 的方式
- 使用 FragmentManager 和 Transaction 的 add 方法:首先,通过在 Activity 中获取 FragmentManager 实例,一般是通过 getSupportFragmentManager ()(在使用 support 库时)或者 getFragmentManager ()(在不使用 support 库的原生环境下)。然后开启一个事务(FragmentTransaction),使用 add 方法将 Fragment 添加到指定的容器视图中。例如,在一个简单的新闻阅读应用中,有一个用于显示新闻列表的 Fragment 和一个用于显示新闻详情的 Fragment。可以在 Activity 的布局文件中预留一个 FrameLayout 作为容器,在需要显示新闻详情时,通过 add 方法将新闻详情 Fragment 添加到这个容器中。
- replace 方法:这种方式会替换掉容器视图中已有的 Fragment。如果之前已经添加了一个 Fragment,使用 replace 会先移除原来的 Fragment,然后添加新的 Fragment。在一个多标签页的应用中,当用户切换标签页,每个标签页对应的 Fragment 可以通过 replace 方法在同一个容器中进行切换,这样可以有效地利用屏幕空间。
- 使用 FragmentPagerAdapter 或 FragmentStatePagerAdapter 与 ViewPager 结合的方式:在这种方式下,Fragment 会与 ViewPager 的页面关联起来。当用户在 ViewPager 中滑动时,Fragment 会根据适配器的设置进行创建、销毁或者恢复。例如,在一个图片浏览应用中,通过 FragmentPagerAdapter 将每个图片对应的 Fragment 与 ViewPager 的页面绑定,用户滑动时可以流畅地浏览不同的图片及其相关的 Fragment 内容。
添加新 Fragment 对已有 Fragment 生命周期的影响(add 方法场景)
- 当使用 add 方法添加一个新 Fragment 时,如果新 Fragment 和已有 Fragment 在同一个容器视图或者相互关联的布局结构中,已有 Fragment 的视图状态通常不会受到直接影响。它的生命周期方法不会因为新 Fragment 的添加而重新执行(除非有其他关联的操作触发)。例如,已有的 Fragment 的 onPause、onResume 等方法不会因为新 Fragment 的添加而被调用。
- 然而,在内存管理方面,如果系统内存紧张,添加新 Fragment 可能会导致之前的 Fragment 被部分回收或者重新调整内存占用。例如,之前 Fragment 中缓存的一些数据或者加载的资源可能会被释放,以腾出空间给新 Fragment。而且,如果新 Fragment 的添加导致布局结构发生变化,影响到已有 Fragment 的可见性或者布局参数,那么已有 Fragment 的相关生命周期方法(如 onHiddenChanged 等)可能会被触发。
Fragment 和 Activity 之间传递数据,Fragment、getActivity () 能否为空,Fragment 和 Activity 如何通信传值。
Fragment 和 Activity 之间的数据传递
- 从 Activity 向 Fragment 传递数据:在创建 Fragment 实例时,可以通过 Fragment 的构造函数或者设置一个公共的方法来传递数据。例如,在 Activity 中有一个用户信息对象,要传递给一个用于显示用户详细信息的 Fragment。可以在创建 Fragment 时,通过构造函数将用户信息对象作为参数传递进去。这样,Fragment 在初始化时就可以获取并使用这些数据。另外,也可以通过 Bundle 来传递数据。在 Activity 中,将数据放入 Bundle,然后在添加 Fragment 时,通过 FragmentTransaction 的 putExtra 方法(类似于 Intent 传递数据的方式)将 Bundle 传递给 Fragment,Fragment 在 onCreate 或者 onViewCreated 方法中可以从 Bundle 中获取数据。
- 从 Fragment 向 Activity 传递数据:一种常见的方式是定义一个接口。在 Fragment 中定义一个接口,在 Activity 中实现这个接口。当 Fragment 需要向 Activity 传递数据时,通过调用接口中的方法来实现。例如,在一个购物车 Fragment 中,当用户修改了商品数量,Fragment 可以通过接口方法将新的商品数量传递给 Activity,Activity 可以根据这个数据来更新总价等相关信息。
关于 Fragment.getActivity () 是否为空的情况
- 在 Fragment 的生命周期早期,如在 onAttach 方法被调用之前,getActivity () 方法会返回空。因为此时 Fragment 还没有与 Activity 关联起来。在 Fragment 的正常生命周期中,只要它没有被从 Activity 中分离或者 Activity 没有被销毁,getActivity () 方法应该返回与之关联的 Activity 实例。但是,如果出现异常情况,比如 Activity 在内存紧张时被意外回收,而 Fragment 还在试图访问 Activity,或者在 Fragment 的异步操作完成后,Activity 已经被销毁,这时候 getActivity () 就可能为空。
Fragment 和 Activity 的通信传值方式详细说明
- 通过接口回调通信:如前面提到的,Fragment 定义一个接口,包含需要传递数据或者触发操作的方法。Activity 实现这个接口,并且在添加 Fragment 时,通过 Fragment 的 onAttach 方法将 Activity 实例(实现了接口的)传递给 Fragment。这样,Fragment 就可以在需要的时候调用接口方法来与 Activity 通信。例如,在一个音乐播放应用中,Fragment 用于控制音乐播放暂停等操作,它可以通过接口将操作事件传递给 Activity,Activity 根据这些事件来更新界面状态或者处理业务逻辑。
- 使用观察者模式通信:可以通过定义一个数据模型类,这个类作为被观察对象。Fragment 和 Activity 都可以注册为这个数据模型的观察者。当数据模型中的数据发生变化时,它会通知所有注册的观察者。例如,在一个天气应用中,有一个用于显示天气信息的 Fragment 和一个包含城市选择功能的 Activity。可以定义一个天气数据模型,Fragment 和 Activity 都观察这个模型。当 Activity 中用户选择了新的城市,数据模型更新天气数据,然后通知 Fragment 和 Activity,Fragment 可以更新天气显示内容,Activity 可以更新城市名称等相关信息。
Activity 的启动模式(分别用于什么场景)
standard 模式
- 这是默认的启动模式。每次启动一个 Activity 时,都会创建一个新的 Activity 实例并放入任务栈中。这种模式适用于大多数普通的场景,例如,在一个新闻应用中,每次用户点击一篇新闻标题进入新闻详情页面,都会创建一个新的新闻详情 Activity。因为用户可能会频繁地在不同新闻之间切换,每个新闻详情页面相互独立,使用 standard 模式可以方便地管理这些独立的页面实例。
singleTop 模式
- 当启动的 Activity 已经位于任务栈的栈顶时,就不会再创建新的实例,而是直接复用栈顶的 Activity 实例。这种模式适用于一些可能会频繁被启动并且有更新需求的场景。例如,在一个聊天应用中,聊天消息列表 Activity 可能会经常被打开。如果用户在聊天过程中通过点击通知或者快捷方式再次打开聊天消息列表 Activity,使用 singleTop 模式可以直接复用已有的栈顶 Activity,这样可以避免重复创建,同时可以在 Activity 的 onNewIntent 方法中处理新的意图(比如更新未读消息计数等)。
singleTask 模式
- 在整个任务栈中,只会存在一个该 Activity 的实例。如果要启动的 Activity 已经存在于任务栈中,那么系统会将这个 Activity 之上的其他 Activity 全部出栈,使该 Activity 位于栈顶。这种模式适用于具有主界面或者核心功能界面的场景。例如,在一个电商应用中,商品列表页面可以设置为 singleTask 模式。当用户在购物流程中从多个不同的商品详情页或者购物车页面返回商品列表页面时,始终只会有一个商品列表 Activity 实例,并且它会处于栈顶,方便用户继续浏览商品。
singleInstance 模式
- 这种模式会为该 Activity 单独创建一个任务栈,并且这个任务栈中只有这一个 Activity 实例。它主要用于一些需要独立运行并且与其他 Activity 隔离的场景。例如,在一个应用中包含一个系统设置的 Activity,这个 Activity 可能需要独立于应用的其他功能页面运行,并且可能会被其他应用或者系统组件调用。使用 singleInstance 模式可以保证这个 Activity 的独立性和稳定性,防止其他 Activity 对其产生干扰。
什么场景适合使用 singleTask?
singleTask 模式适用于具有主界面或者核心功能界面的场景。
以一个地图导航应用为例,地图显示 Activity 可以采用 singleTask 模式。因为地图是整个应用的核心功能,用户在使用过程中可能会频繁地从其他辅助功能页面(如路线规划页面、地点搜索页面等)返回地图显示页面。当用户从路线规划页面返回地图显示 Activity 时,使用 singleTask 模式可以保证只有一个地图显示 Activity 实例在任务栈中,并且它会被置于栈顶。这样可以避免创建多个重复的地图显示 Activity,节省系统资源。同时,也能保证用户始终回到同一个地图显示界面,不会出现多个地图实例导致的混乱。
在一个多媒体播放应用中,播放界面也适合使用 singleTask 模式。用户可能会从播放列表页面、歌词显示页面或者设置页面等返回播放界面。通过 singleTask 模式,无论用户从哪个页面返回,播放界面始终只有一个实例在任务栈中,方便用户对播放操作进行控制,如暂停、播放、切换歌曲等。而且,这种模式可以确保播放界面的状态(如当前播放进度、音量等)在用户反复进出不同辅助页面后依然保持一致,提升用户体验。
讲讲 Activity 生命周期
Activity 生命周期是 Android 开发中非常重要的概念,它描述了一个 Activity 从创建到销毁的整个过程。
首先是 onCreate 方法,这是 Activity 被创建时调用的第一个方法。在这个阶段,主要进行一些初始化的操作,比如设置布局资源(通过 setContentView 方法)、初始化视图组件、绑定数据等。例如,在一个登录 Activity 中,在 onCreate 阶段可以加载包含用户名和密码输入框以及登录按钮的布局文件,同时可以初始化一些相关的数据,如设置输入框的默认提示文本等。
接着是 onStart 方法,当 Activity 变得可见但还没有获取焦点时调用。此时,Activity 已经在屏幕上显示,但用户还不能与之交互。这个阶段可以进行一些和视图显示相关的操作,比如启动一些动画效果,让视图以动画的方式出现。
然后是 onResume 方法,当 Activity 获取焦点,可以与用户交互时调用。在这个阶段,通常可以进行一些需要用户交互的操作准备,如开启传感器监听等。例如,在一个运动记录的 Activity 中,在 onResume 阶段可以开启加速度传感器来记录用户的运动状态。
当有其他 Activity 覆盖当前 Activity 时,会调用 onPause 方法。在这个阶段,应该暂停一些耗时的操作,如暂停动画、停止传感器监听等。但要注意的是,onPause 方法执行速度要快,因为如果新的 Activity 是透明的或者半透明的,用户可能很快就会看到当前 Activity,所以不能在这里进行复杂的操作。
如果 Activity 完全被遮挡,就会调用 onStop 方法。在这个阶段,可以释放一些资源,如停止一些与界面显示相关的后台线程等。不过,Activity 依然有可能会被重新显示,所以不是所有资源都要释放。
当 Activity 要被销毁时,会调用 onDestroy 方法。在这里可以进行最后的资源清理工作,如解除广播注册、释放数据库连接等。如果 Activity 是因为系统内存不足等原因被销毁,onDestroy 可能会被强制调用,此时要确保重要的数据被妥善保存。
Activity A 打开 Activity B 两个 Activity 的生命周期变化
当 Activity A 启动 Activity B 时,首先 Activity A 会执行 onPause 方法。这是因为系统要将焦点从 Activity A 转移到 Activity B,所以 Activity A 需要暂停一些操作,比如暂停正在播放的动画或者停止正在进行的传感器监听。
接着 Activity B 会依次执行 onCreate、onStart 和 onResume 方法。在 onCreate 阶段,Activity B 会进行初始化操作,如加载布局、初始化视图组件等。onStart 方法使得 Activity B 变得可见,onResume 方法则让 Activity B 获取焦点,可以和用户进行交互。
此时,Activity A 仍然处于暂停状态,在内存中保留着。如果系统内存不足,Activity A 有可能会被进一步销毁,但在正常情况下,它会一直保持暂停状态,直到 Activity B 被关闭或者被其他操作影响。
当 Activity B 被关闭并返回 Activity A 时,Activity B 会先执行 onPause 方法,然后执行 onStop 和 onDestroy 方法来结束自己的生命周期。而 Activity A 会从 onPause 状态恢复,依次执行 onRestart、onStart 和 onResume 方法,重新获取焦点,继续和用户进行交互。
讲讲 onSaveInstanceState 和 onRestoreInstanceState 作用
onSaveInstanceState 方法主要用于在 Activity 可能被销毁之前保存一些重要的数据。这种情况通常发生在系统配置发生变化(如屏幕旋转)或者系统内存不足需要回收 Activity 时。
在 onSaveInstanceState 方法中,可以将 Activity 的一些状态数据保存到 Bundle 对象中。例如,在一个文本编辑 Activity 中,可以将用户已经输入的文本内容保存到 Bundle 中。这个 Bundle 对象会在 Activity 重新创建时被传递回来。它可以保存各种基本数据类型,如字符串、整数等,也可以保存一些实现了 Parcelable 接口的复杂对象。
onRestoreInstanceState 方法则是用于在 Activity 重新创建时恢复之前保存的数据。这个方法会在 onCreate 方法之后被调用,并且传入的 Bundle 对象就是之前在 onSaveInstanceState 方法中保存的数据。通过从 Bundle 中取出数据,可以恢复 Activity 之前的状态。例如,从 Bundle 中取出之前保存的文本内容,然后将其设置到文本编辑框中,这样用户就不会因为 Activity 的重新创建而丢失之前输入的内容。
需要注意的是,onSaveInstanceState 方法并不是一定会被调用。只有在 Activity 确实需要被销毁并且有可能会被重新创建时才会调用。例如,当用户按下返回键关闭 Activity 时,onSaveInstanceState 方法不会被调用,因为 Activity 不会被重新创建。
ViewModel 怎么实现的,ViewModel 是不是一直存在内存中(比如我开启了很多页面,或者 ViewModel 持有 Bitmap 这样的对象)
ViewModel 的实现主要是为了解决 Activity 和 Fragment 等 UI 组件在配置变化(如屏幕旋转)或者复杂的生命周期场景下的数据保存和共享问题。
ViewModel 是通过 Android 架构组件提供的 ViewModel 类来实现的。它通常是在一个 Activity 或者 Fragment 的范围内被创建和使用。当创建一个 ViewModel 时,系统会将其与对应的 Activity 或者 Fragment 的生命周期关联起来。
在实现过程中,ViewModel 会利用一个名为 ViewModelStore 的类来存储 ViewModel 实例。这个存储机制可以确保在 Activity 或者 Fragment 的生命周期变化过程中,ViewModel 能够正确地被保存和恢复。例如,当屏幕旋转时,Activity 会被重新创建,但是通过 ViewModelStore,之前创建的 ViewModel 可以被重新获取,而不是重新创建一个新的,这样就可以保证数据的连续性。
关于 ViewModel 是否一直存在内存中,它并不是一直存在的。虽然 ViewModel 的设计目的是在配置变化时能够保存数据,但是它的生命周期是和与之关联的 Activity 或者 Fragment 的生命周期相关的。当 Activity 或者 Fragment 被销毁并且不再需要重新创建时(比如用户通过返回键退出 Activity),ViewModel 也会随之被销毁。
然而,在一些复杂的场景下,如开启了很多页面,ViewModel 会根据其关联的 Activity 或者 Fragment 的生命周期来管理自己的存在。如果一个 ViewModel 被多个 Fragment 共享,只要这些 Fragment 所属的 Activity 没有被销毁,ViewModel 就会一直存在。
当 ViewModel 持有 Bitmap 这样的对象时,需要谨慎处理。因为 Bitmap 可能会占用大量的内存空间。如果处理不当,可能会导致内存泄漏或者内存溢出。在这种情况下,应该根据具体的情况来决定是否需要对 Bitmap 进行缓存或者释放。例如,可以使用一些内存缓存策略来管理 Bitmap,当 ViewModel 不再需要这个 Bitmap 时,及时释放它,以避免内存问题。
自定义 View 如何实现?
自定义 View 主要有两种方式,一种是继承现有的 View 类,另一种是继承 ViewGroup 类来创建复合视图。
如果是继承 View 类,首先要确定自定义 View 的功能和外观。例如,要创建一个带有圆形背景和数字显示的自定义 View 用于显示倒计时。
在构造函数中,可以进行一些初始化操作。可以接收一些自定义的参数,如圆形的颜色、数字的大小和颜色等。例如,通过定义带有参数的构造函数,可以让使用者在布局文件中或者代码中方便地设置这些属性。
然后需要重写 onMeasure 方法来确定自定义 View 的大小。对于这个倒计时的自定义 View,可以根据数字的大小、圆形的半径等因素来计算出合适的宽度和高度。在 onMeasure 方法中,需要根据传入的 MeasureSpec 参数来正确地设置 View 的大小,确保它能够在布局中合理地显示。
接着是重写 onDraw 方法来绘制自定义 View 的外观。对于倒计时的 View,可以使用 Canvas 对象来绘制圆形背景和数字。可以使用 Paint 对象来设置颜色、字体等属性。例如,通过设置 Paint 的颜色为红色,就可以将数字绘制为红色,通过设置画笔的样式为填充,可以绘制出实心的圆形背景。
如果是继承 ViewGroup 类来创建复合视图,比如要创建一个包含标题、内容区域和按钮的自定义布局。首先在构造函数中初始化 ViewGroup,并且可以接收一些布局相关的参数。
然后需要重写 onMeasure 方法来测量子视图的大小并确定自身的大小。在这个过程中,需要遍历所有的子视图,调用它们的 measure 方法来获取它们的大小,并根据布局规则(如线性排列、相对排列等)来计算自身的大小。
接着重写 onLayout 方法来确定子视图的位置。根据布局规则,将子视图放置在合适的位置上。例如,对于线性排列的复合视图,可以根据子视图的大小和间距,依次将它们排列在水平或者垂直方向上。
如何创建一个线程?
在 Android 中创建线程有多种方法。
一种常见的方式是通过继承 Thread 类。首先创建一个新的类,让它继承自 Thread 类,然后重写 run 方法。在 run 方法中定义线程需要执行的任务。例如,要创建一个简单的线程用于在后台进行数据下载,可以像这样创建:
class MyDownloadThread extends Thread {
@Override
public void run() {
// 在这里编写下载数据的代码,比如通过网络请求获取文件内容
// 可以使用HttpURLConnection等工具
}
}
之后,在需要启动线程的地方,通过创建这个类的实例并调用 start 方法来开启线程。比如在一个 Activity 的按钮点击事件中:
MyDownloadThread thread = new MyDownloadThread();
thread.start();
另外,还可以通过实现 Runnable 接口来创建线程。定义一个类实现 Runnable 接口,在接口的 run 方法中编写任务代码。例如:
class MyRunnableTask implements Runnable {
@Override
public void run() {
// 执行任务,和上面继承Thread类时在run方法中写的类似
}
}
然后创建 Thread 对象,将实现了 Runnable 接口的对象作为参数传入。例如:
MyRunnableTask task = new MyRunnableTask();
Thread thread = new Thread(task);
thread.start();
这种方式的好处是更符合面向对象的设计原则,因为一个类可以实现多个接口,通过实现 Runnable 接口可以让这个类在不同的线程场景下复用。同时,Java 中的线程池框架通常也是以 Runnable 接口为基础来接收任务的。
还有一种使用匿名内部类的方式。例如,在一个方法内部直接创建一个 Thread 对象,并且在构造函数中传入一个实现了 Runnable 接口的匿名内部类。比如:
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// 这里写任务代码
}
});
thread.start();
这种方式适合于简单的、临时性的线程任务,不需要单独定义一个类来实现。
静态内部类和内部类的区别。
内部类是定义在另一个类内部的类。它可以访问外部类的成员变量和方法,即使这些成员是私有的。这是因为内部类会持有一个外部类的引用,通过这个引用可以访问外部类的成员。
例如,有一个外部类 Outer 和一个内部类 Inner,在 Inner 类中可以直接访问 Outer 类的成员:
class Outer {
private int outerVariable = 10;
class Inner {
public void accessOuterVariable() {
System.out.println(outerVariable);
}
}
}
然而,静态内部类和内部类有很大的不同。静态内部类是被声明为静态的内部类。它不持有外部类的实例引用,这意味着它不能直接访问外部类的非静态成员变量和方法。
从内存角度看,内部类的实例依赖于外部类的实例存在。每次创建内部类的实例时,都会隐式地关联一个外部类的实例。而静态内部类可以独立存在,不需要外部类的实例。
例如,在静态内部类中,如果要访问外部类的成员,那些成员必须是静态的:
class Outer {
private static int outerStaticVariable = 20;
static class StaticInner {
public void accessOuterStaticVariable() {
System.out.println(outerStaticVariable);
}
}
}
在使用场景方面,内部类通常用于和外部类紧密相关的逻辑,并且需要频繁访问外部类的实例成员。比如在一个 Activity 中定义一个内部类来处理和这个 Activity 的视图相关的异步任务,这个内部类可以方便地访问 Activity 的视图和其他成员。
而静态内部类适用于和外部类有一定关联,但不需要依赖外部类的具体实例的情况。例如,在一个工具类中定义一个静态内部类来实现一些和这个工具类相关的常量或者算法,这些常量和算法不依赖于工具类的具体实例状态。
Activity 打开一个小 Dialog 并关闭的生命周期过程。
当 Activity 打开一个 Dialog 时,Activity 的生命周期状态基本保持不变。
Activity 仍然处于可见和可交互的状态,也就是处于 onResume 阶段。因为 Dialog 是浮在 Activity 之上的,它不会改变 Activity 本身的焦点状态和可见状态。不过,在某些特殊情况下,如果 Dialog 是全屏的或者半透明的并且遮挡了 Activity 的主要部分,用户可能无法直接和 Activity 进行交互,但 Activity 的生命周期方法不会因此而改变。
对于 Dialog 本身,它有自己的生命周期。当通过 Dialog 的构造函数或者相关的 Builder 方法创建 Dialog 后,它并没有显示出来。当调用 Dialog 的 show 方法时,Dialog 开始显示在屏幕上。
在 Dialog 显示期间,它可以接收用户的交互,比如点击按钮等操作。如果 Dialog 中有一些复杂的视图,例如包含列表视图或者自定义视图,这些视图的生命周期方法会根据用户的操作和系统的调度来执行。
当 Dialog 被关闭,例如通过点击 Dialog 中的关闭按钮或者在代码中调用 dismiss 方法,Dialog 的生命周期结束。它会从屏幕上消失,相关的资源会被释放。例如,Dialog 中的视图会被销毁,与之关联的事件监听器等也会被解除。
整个过程中,Activity 一直处于 onResume 状态,除非有其他外部因素干扰,比如系统配置变化或者有新的 Activity 启动来覆盖当前的 Activity 和 Dialog 组合。这种情况下,Activity 会按照正常的生命周期流程进行相应的方法调用,如 onPause、onStop 等,而 Dialog 也会随之隐藏或者被销毁。
广播的两种注册方法,区别是什么?
广播在 Android 中有两种注册方法,分别是静态注册和动态注册。
静态注册是在 AndroidManifest.xml 文件中进行注册。通过在清单文件中使用<receiver>标签来声明广播接收器。例如,要注册一个接收系统开机广播的接收器,可以这样写:
<receiver android:name=".MyBroadcastReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
这种方式的优点是,广播接收器可以在应用没有启动的情况下接收到广播。只要应用安装在设备上,当匹配的广播事件发生时,系统就会自动启动应用并调用广播接收器的 onReceive 方法。这对于一些需要在系统事件发生时自动执行的任务非常有用,比如开机自动启动服务或者执行一些初始化操作。
但是,静态注册也有一些缺点。由于系统需要维护这些注册信息,可能会对系统资源有一定的占用。而且,如果应用中有很多不必要的静态注册广播接收器,可能会导致系统性能下降。
动态注册是在代码中通过 Context.registerReceiver 方法来进行注册。例如,在一个 Activity 的 onCreate 方法中可以这样注册一个广播接收器:
MyBroadcastReceiver receiver = new MyBroadcastReceiver();
IntentFilter filter = new IntentFilter();
filter.addAction("com.example.mybroadcast");
registerReceiver(receiver, filter);
动态注册的优点是灵活性高。可以在应用运行的过程中根据需要随时注册和注销广播接收器。例如,在一个音乐播放应用中,只有当用户进入播放页面时,才动态注册一个接收耳机插拔广播的接收器,当用户离开播放页面时就注销这个接收器。
然而,动态注册的广播接收器的生命周期和注册它的组件(如 Activity)的生命周期相关。如果注册广播接收器的组件被销毁,而没有正确地注销广播接收器,可能会导致内存泄漏或者接收不到广播的情况。例如,在一个 Activity 中动态注册了广播接收器,当 Activity 被销毁时,如果没有在 onDestroy 方法中注销广播接收器,当广播事件再次发生时,可能会导致应用崩溃或者出现异常行为。
Service 的生命周期,用法,Service 运行在哪个线程?
Service 的生命周期主要包括以下几个重要阶段。
首先是 onCreate 方法,这个方法在 Service 被创建时调用。在这个阶段可以进行一些初始化操作,比如初始化一些用于数据加载或者网络通信的工具类。例如,在一个音乐播放服务中,可以在 onCreate 阶段初始化音乐播放引擎,加载播放列表等。
接着是 onStartCommand 方法,当通过 startService 方法启动 Service 时,这个方法会被调用。它接收一个 Intent 对象作为参数,这个 Intent 可以包含启动服务的相关信息,比如要播放的音乐的资源 ID 等。在 onStartCommand 方法中,可以根据 Intent 中的信息来执行具体的服务任务。这个方法返回一个整数,用于指示系统在 Service 被意外终止后如何重新启动它。
如果 Service 是通过 bindService 方法绑定的,会调用 onBind 方法。这个方法返回一个 IBinder 对象,用于和绑定的组件(如 Activity)进行通信。例如,在一个位置更新服务中,通过 onBind 方法返回的 IBinder 对象,Activity 可以获取位置信息并且在界面上显示。
当 Service 不再需要被使用时,会调用 onDestroy 方法来进行资源清理。例如,在前面提到的音乐播放服务中,可以在 onDestroy 阶段释放音乐播放引擎占用的资源,关闭网络连接等。
Service 的用法很广泛。它可以用于在后台执行长时间运行的任务,如文件下载、音乐播放、数据同步等。例如,在一个文件下载应用中,可以创建一个 Service 来在后台执行下载任务,这样即使用户离开应用的界面,下载任务依然可以继续进行。
关于 Service 运行在哪个线程,Service 本身是运行在主线程的。这意味着如果在 Service 中执行一些耗时的操作,如长时间的网络请求或者复杂的计算,会阻塞主线程,导致应用出现卡顿甚至无响应(ANR)的情况。为了避免这种情况,可以在 Service 中创建新的线程或者使用线程池来执行耗时操作。例如,在一个数据同步服务中,可以在 Service 中创建一个新的线程来进行数据的上传和下载,这样就不会影响主线程的正常运行。
ContentProvider 作用。
ContentProvider 是 Android 中的一个重要组件,它的主要作用是在不同的应用之间实现数据共享。
在 Android 系统中,每个应用都有自己的沙盒环境,数据是相互隔离的。但有时候,我们需要让一个应用的数据能够被其他应用访问,这时候 ContentProvider 就发挥作用了。例如,通讯录应用中的联系人数据,短信应用中的短信内容等,这些数据可以通过 ContentProvider 暴露给其他应用。
它通过定义一套标准的接口来提供数据访问。其他应用可以使用 ContentResolver 来访问 ContentProvider 提供的数据。以查询联系人数据为例,首先在提供联系人数据的应用中,通过继承 ContentProvider 类,实现 query、insert、update 和 delete 等方法来定义数据的访问方式。在 query 方法中,会根据传入的 Uri(统一资源标识符)、选择条件、排序方式等参数来返回相应的联系人数据。
对于数据的访问权限,可以在 ContentProvider 中进行精细的控制。通过设置权限,可以决定哪些应用能够访问以及如何访问数据。例如,一个应用可以将自己的 ContentProvider 的读权限开放给其他应用,但限制写权限,这样其他应用只能读取数据而不能修改。
ContentProvider 还可以用于跨进程通信。因为不同的应用运行在不同的进程中,ContentProvider 在底层通过 Binder 机制来实现跨进程的数据传递。这种跨进程通信的方式使得数据共享更加安全和高效。在一些复杂的应用架构中,比如一个包含多个模块的大型应用,各个模块可能运行在不同的进程中,ContentProvider 可以用于在这些模块之间共享数据,如配置信息、用户偏好等。
android 图片加载如何计算图片大小?
在 Android 中计算图片大小,首先要考虑图片的存储格式和尺寸。
对于存储格式,常见的有 JPEG、PNG 等。JPEG 是一种有损压缩格式,PNG 是无损压缩格式。在计算大小的时候,JPEG 的大小取决于其压缩质量和图像内容的复杂程度。一般来说,压缩质量越高,文件大小越大。PNG 文件大小主要取决于图像的颜色数量、透明度等因素。
从图像尺寸角度,图片大小(以字节为单位)可以通过像素数量乘以每个像素占用的字节数来大致计算。例如,一张 RGB 格式的图片,每个像素占用 3 个字节(因为 RGB 每个通道占用 1 个字节)。如果图片的分辨率是 1920×1080 像素,那么这张图片未压缩时的大小大约是 1920×1080×3 字节。
在 Android 的实际开发中,当从资源文件或者网络加载图片时,还需要考虑内存中的占用情况。Android 系统会根据设备的屏幕密度等因素对图片进行缩放处理。例如,在一个低密度屏幕的设备上加载高分辨率的图片,系统可能会对图片进行下采样,以减少内存占用。
当使用 BitmapFactory 类来加载图片时,可以通过设置 inSampleSize 参数来控制图片的采样率。这个参数表示采样后的图片边长与原始图片边长的比例。例如,inSampleSize 为 2 表示采样后的图片边长是原始图片边长的 1/2,这样内存占用就会减少为原来的 1/4(因为面积是边长的平方关系)。
另外,在内存中,除了像素数据占用的空间,还有一些额外的开销用于存储图片的元数据,如颜色模式、透明度信息等。这些开销虽然相对像素数据占用空间较小,但在计算图片在内存中的总占用时也需要考虑。
Java 内存分为哪些区域,堆内存溢出、栈溢出相同吗,哪些场景下会发生栈溢出,创建的对象一般在哪个区域,GCRoots 有哪些。
Java 内存主要分为以下几个区域。
首先是程序计数器,它是一块较小的内存空间,用于记录当前线程所执行的字节码行号指示器。这个区域是线程私有的,它的作用是保证线程切换后能够恢复到正确的执行位置。
其次是 Java 虚拟机栈,它也是线程私有的。每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。当一个方法被调用时,一个新的栈帧就会被压入栈中,当方法执行结束时,栈帧就会弹出。
然后是本地方法栈,和 Java 虚拟机栈类似,不过它是为本地方法(用其他语言编写的方法,如 C 或 C++)服务的。
堆是 Java 内存中最大的一块区域,它是被所有线程共享的。主要用于存放对象实例。在 Java 中,几乎所有的对象都是在堆中分配内存的。
方法区也是共享的区域,用于存储已被加载的类信息、常量、静态变量、即时编译器编译后的代码等。
堆内存溢出和栈溢出是不同的。堆内存溢出主要是因为在堆中创建了过多的对象,导致没有足够的空间来分配新的对象。例如,在一个循环中不断地创建新的对象,并且没有及时释放(在没有垃圾回收或者垃圾回收不及时的情况下),就可能会导致堆内存溢出。
栈溢出通常发生在方法调用层级过深或者局部变量占用空间过大的情况。比如一个递归方法没有正确的终止条件,导致不断地调用自身,栈帧不断地被压入栈中,最终导致栈溢出。
创建的对象一般在堆区域。GCRoots(垃圾回收根对象)主要包括以下几种。一是虚拟机栈中引用的对象,也就是在方法栈帧中的局部变量所引用的对象。二是方法区中类静态属性引用的对象。三是本地方法栈中引用的对象(在使用本地方法时)。这些 GCRoots 作为起始点,在垃圾回收过程中,从这些点开始向下搜索,能够被搜索到的对象被认为是存活的,否则就是可以被回收的。
复制算法 (GC 算法之一) 的流程,简单介绍 GC 算法,采用复制算法的原理(从操作系统层次讲)。
GC(垃圾回收)算法是用于回收内存中不再使用的对象所占用的空间,以防止内存泄漏并提高内存利用率。
复制算法是一种比较简单的 GC 算法。它的基本流程是将内存空间划分为两个大小相等的区域,一般称为 From 区和 To 区。当对象被创建时,它们都被分配到 From 区。在垃圾回收时,会遍历 From 区中的所有对象,标记出存活的对象。然后将这些存活的对象复制到 To 区,接着清空 From 区。下一次垃圾回收时,角色互换,原来的 To 区变成 From 区,原来的 From 区变成 To 区,重复上述过程。
从简单介绍 GC 算法的角度看,除了复制算法,还有标记 - 清除算法和标记 - 整理算法等。标记 - 清除算法是先标记出所有需要回收的对象,然后统一回收这些对象所占用的空间。但这种算法会产生内存碎片。标记 - 整理算法是在标记 - 清除算法的基础上,将存活的对象向一端移动,然后清理掉边界以外的内存空间,这样可以减少内存碎片。
从操作系统层次讲复制算法的原理,它主要是基于内存空间的高效利用和简单的回收机制。通过将内存划分为两个区域,在复制存活对象的过程中,实际上是对内存空间进行了重新组织。这种方式避免了像标记 - 清除算法那样产生内存碎片的问题。因为每次回收后,To 区中的对象都是紧密排列的。而且,复制算法的实现相对简单,在存活对象较少的情况下,它的性能比较高。但是,它的缺点是需要将存活对象进行复制,这会消耗一定的时间和内存资源。特别是当存活对象较多时,复制的成本会比较高。另外,由于需要将内存划分为两个相等的区域,它的空间利用率相对较低,最多只能使用一半的内存空间来存放对象。
判断对象是否死亡的方法,GC ROOT 能是哪些,内存模型中哪部分需要 GC,栈是否需要 GC,GC 算法有哪些。
判断对象是否死亡主要有两种方法。一是引用计数法,另一个是可达性分析算法。
引用计数法是比较简单的方式,它通过给每个对象添加一个引用计数器。当有一个地方引用这个对象时,计数器就加 1,当引用失效时,计数器就减 1。当计数器的值为 0 时,就认为这个对象是可以回收的。但是这种方法有一个缺陷,就是无法解决循环引用的问题。例如,两个对象互相引用,它们的引用计数都不为 0,但实际上这两个对象可能已经没有其他地方引用,应该被回收。
可达性分析算法是目前主流的判断方法。它以 GCRoots 为起始点,通过一系列称为 “引用链” 的对象引用关系来搜索。如果一个对象到任何一个 GCRoots 都没有引用链相连,那么这个对象就是不可达的,被认为是可以回收的。
GCRoots 可以是虚拟机栈中引用的对象,比如一个方法中的局部变量引用的对象。也可以是方法区中类静态属性引用的对象,像一个类的静态变量引用的对象。还可以是本地方法栈中引用的对象,当使用本地方法时,其中引用的对象也是 GCRoots。
在 Java 内存模型中,堆是主要需要 GC 的部分。因为堆是存放对象实例的区域,对象的创建和销毁比较频繁,容易产生内存垃圾。而栈一般不需要 GC。因为栈中的数据是随着方法的调用和结束自动管理的。当一个方法执行结束,它的栈帧就会弹出,栈帧中的局部变量等数据就会自动释放。
常见的 GC 算法有复制算法,前面已经详细介绍过,它通过划分两个区域来复制存活对象进行垃圾回收。还有标记 - 清除算法,先标记需要回收的对象,然后清除这些对象占用的空间。不过这种算法会产生内存碎片。标记 - 整理算法是在标记 - 清除的基础上,将存活对象向一端移动,然后清理边界以外的空间,减少内存碎片。另外还有分代收集算法,它根据对象的存活周期将堆分为新生代和老生代,针对不同代采用不同的 GC 算法,比如在新生代中可以使用复制算法,因为新生代中对象的存活率相对较低,而在老生代可以使用标记 - 清除或者标记 - 整理算法。
讲讲 final、finally、finalize 区别。
- final:
- 用于修饰类:当一个类被标记为 final 时,这个类不能被继承。这意味着它的设计意图是作为一个完整的、不可扩展的类存在。例如,Java 中的 String 类是 final 类,这保证了它的行为和状态在整个 Java 环境中的一致性。因为如果可以随意继承 String 类,可能会导致一些不符合预期的行为,比如改变字符串的比较规则或者存储方式等。
- 用于修饰方法:被 final 修饰的方法不能在子类中被重写。这可以用于保护方法的实现逻辑,确保在继承体系中,该方法的行为不会被改变。比如在一个工具类中,有一个计算折扣后的价格的方法,这个方法的计算逻辑是固定的,为了防止子类错误地修改这个逻辑,可以将其标记为 final。
- 用于修饰变量:如果是基本数据类型的变量,一旦被赋值,其值就不能再改变。例如,final int num = 10; 之后就不能再对 num 进行赋值操作。如果是引用类型的变量,它所指向的对象不能再改变,但对象本身的内容可以改变。例如,
final ArrayList<String> list = new ArrayList<>(); 不能再让 list 指向其他的 ArrayList 对象,但可以对 list 中的元素进行添加、删除等操作。
- finally:
- finally 是用于异常处理的关键字。它和 try - catch 语句一起使用,无论 try 块中的代码是否抛出异常,finally 块中的代码都会被执行。这在需要进行资源清理的场景中非常有用。例如,在打开一个文件进行读取操作后,无论读取过程中是否出现异常,都需要在 finally 块中关闭文件,以防止资源泄漏。如:
try {
// 打开文件读取操作
} catch (IOException e) {
// 处理异常
} finally {
// 关闭文件
}
- finalize:
- finalize 是 Object 类中的一个方法。当一个对象即将被垃圾回收时,这个方法会被调用。不过,它的调用时机是不确定的,并且在现代的 Java 编程中,不推荐依赖 finalize 方法来进行资源清理等操作。因为垃圾回收的过程是由 JVM 的垃圾回收器控制的,它可能不会及时地调用 finalize 方法,甚至可能根本不调用。例如,在一个自定义类中重写了 finalize 方法来释放一些非 Java 资源(如通过本地方法调用的 C++ 资源),但不能保证这些资源会在预期的时间被释放,所以应该使用更可靠的资源清理方式,如 try - finally。
讲讲重载和重写的区别。
- 重载(Overloading):
- 重载发生在同一个类中。它是指方法名相同,但参数列表(包括参数的类型、个数、顺序)不同的多个方法。返回值类型可以相同也可以不同。例如,在一个数学计算类中,可以有两个名为 add 的方法,一个方法接受两个整数参数用于整数相加,另一个方法接受两个浮点数参数用于浮点数相加。
- 编译器会根据调用方法时传入的实际参数来决定调用哪个重载方法。这是在编译阶段就确定的。当进行方法调用时,编译器会检查参数的类型等信息,选择最合适的重载方法。例如,在调用 add (1, 2) 时,编译器会选择接受两个整数参数的 add 方法;如果调用 add (1.0f, 2.0f),则会选择接受两个浮点数参数的 add 方法。
- 重载的目的主要是为了提供更灵活的方法调用方式,方便程序员根据不同的参数情况使用同一个方法名来完成相似的功能。
- 重写(Overriding):
- 重写发生在子类和父类之间。当子类想要改变父类中某个方法的行为时,可以对该方法进行重写。重写的方法必须和父类中被重写的方法具有相同的方法名、参数列表和返回值类型(或者是返回值类型为父类方法返回值类型的子类型)。例如,在一个动物类中有一个叫 makeSound 的方法,在子类狗类中可以重写这个方法来发出狗叫的声音,而不是继承动物类的通用声音。
- 重写的方法不能比父类中被重写的方法有更严格的访问限制。例如,如果父类中的方法是 public 的,子类重写后的方法不能是 private 的。而且,重写方法抛出的异常类型也需要和父类方法抛出的异常类型兼容,不能抛出新的、更宽泛的异常类型,除非是运行时异常。
- 与重载不同,重写是在运行时根据对象的实际类型来决定调用哪个方法。如果一个对象是子类类型,那么调用重写后的方法;如果是父类类型,调用父类中的方法。这使得程序可以根据对象的具体类型来展现不同的行为。
讲讲泛型、泛型擦除。
- 泛型(Generics):
- 泛型是 Java 中的一个特性,它提供了一种参数化类型的方式。通过使用泛型,可以在编译时检查类型的安全性,减少类型转换的错误。例如,在创建一个集合时,可以使用泛型来指定集合中元素的类型。如
ArrayList<String>表示这个 ArrayList 中只能存储 String 类型的元素。 - 泛型有多种形式,包括泛型类、泛型接口和泛型方法。对于泛型类,类型参数在类名后面定义,在整个类中可以使用这个类型参数来定义成员变量、方法参数和返回值等。例如,一个简单的泛型类可以这样定义:
- 泛型是 Java 中的一个特性,它提供了一种参数化类型的方式。通过使用泛型,可以在编译时检查类型的安全性,减少类型转换的错误。例如,在创建一个集合时,可以使用泛型来指定集合中元素的类型。如
class Box<T> {
private T content;
public void setContent(T content) {
this.content = content;
}
public T getContent() {
return content;
}
}
- 泛型接口类似,在接口名称后面定义类型参数。泛型方法则是在方法返回值类型之前定义类型参数,并且这个方法可以独立于所在的类或者接口是否是泛型而存在。
- 泛型的主要优点是提高代码的可读性和可维护性。它让代码的意图更加清晰,比如在一个方法中接收一个泛型集合,从方法签名就可以清楚地知道这个集合中元素的类型。同时,它也增强了类型安全,减少了因为类型不匹配导致的运行时错误。
- 泛型擦除(Type Erasure):
- 泛型擦除是 Java 泛型实现的一个重要概念。在 Java 编译过程中,所有的泛型信息会被擦除。也就是说,在字节码层面,泛型类型参数会被替换为它们的边界类型或者 Object 类型。例如,对于
ArrayList<String>和ArrayList<Integer>,在字节码中它们的类型都是 ArrayList,泛型类型信息被擦除了。 - 这是因为 Java 的虚拟机在早期设计时并没有考虑泛型,为了兼容旧的代码和虚拟机,采用了泛型擦除的方式。但是,这也带来了一些限制。比如不能通过反射获取泛型类型参数的实际类型(在没有额外的信息存储机制的情况下)。不过,可以通过一些技巧,如在类中添加一个额外的类型变量来保存泛型类型信息,来解决部分问题。
- 由于泛型擦除的存在,在编写代码时需要注意一些情况。例如,在进行类型转换时,虽然在编译时通过泛型检查了类型安全,但在运行时由于泛型擦除,可能会出现类型转换错误。所以在进行一些涉及泛型的复杂操作时,需要谨慎处理。
- 泛型擦除是 Java 泛型实现的一个重要概念。在 Java 编译过程中,所有的泛型信息会被擦除。也就是说,在字节码层面,泛型类型参数会被替换为它们的边界类型或者 Object 类型。例如,对于
讲讲 JVM 组成。
JVM(Java Virtual Machine)主要由以下几个部分组成。
- 类加载器(Class Loader):
- 类加载器负责加载字节码文件到 JVM 的内存中。它有不同的层次结构,包括启动类加载器、扩展类加载器和应用程序类加载器等。启动类加载器主要负责加载 Java 的核心类库,如 java.lang 包中的类。这些类是 JVM 运行的基础,比如 Object 类、String 类等。扩展类加载器用于加载 Java 的扩展类库,通常位于 JDK 的扩展目录下。应用程序类加载器则负责加载应用程序自己定义的类。
- 类加载过程包括加载、验证、准备、解析和初始化几个阶段。在加载阶段,类加载器会根据类的全限定名来查找字节码文件,并将其加载到内存中。验证阶段会检查字节码文件的格式是否正确、是否符合 Java 虚拟机规范等。准备阶段会为类的静态变量分配内存并设置默认初始值。解析阶段会将类中的符号引用转换为直接引用。初始化阶段会执行类的构造方法,对静态变量进行初始化。
- 运行时数据区(Runtime Data Areas):
- 这是 JVM 内存管理的核心部分,主要包括程序计数器、Java 虚拟机栈、本地方法栈、堆和方法区。程序计数器用于记录当前线程所执行的字节码行号指示器,它是线程私有的,保证线程切换后能够恢复到正确的执行位置。Java 虚拟机栈也是线程私有的,每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。本地方法栈和 Java 虚拟机栈类似,不过它是为本地方法(用其他语言编写的方法,如 C 或 C++)服务的。
- 堆是 Java 内存中最大的一块区域,它是被所有线程共享的,主要用于存放对象实例。几乎所有的对象都是在堆中分配内存的。方法区也是共享的区域,用于存储已被加载的类信息、常量、静态变量、即时编译器编译后的代码等。
- 执行引擎(Execution Engine):
- 执行引擎负责执行字节码指令。它可以采用解释执行和编译执行两种方式。解释执行是逐行解释字节码指令并执行,这种方式启动速度快,但执行效率相对较低。编译执行是将字节码指令先编译成机器语言,然后再执行,这种方式执行效率高,但编译过程需要消耗时间和资源。在实际的 JVM 中,通常会采用混合的方式,先进行解释执行,当发现某个方法或代码块执行频率较高时,再进行编译执行,这种方式称为即时编译(Just - in - Time Compilation,JIT)。
- 本地方法接口(Native Method Interface):
- 本地方法接口用于和本地方法(Native Method)进行交互。本地方法是用其他语言(如 C 或 C++)编写的方法,它们可以访问操作系统的底层资源或者执行一些高性能的操作。通过本地方法接口,Java 代码可以调用这些本地方法,从而扩展 Java 程序的功能。例如,在 Java 中进行一些底层的文件操作或者与硬件设备交互时,可能会使用本地方法。
数组和链表的区别,linkedlist 能否 get (index)。
- 数组和链表的区别:
- 存储结构:
- 数组是一种连续的存储结构,它在内存中占用一段连续的空间。元素在数组中的位置是通过索引来确定的,索引从 0 开始。例如,一个整数数组 int [] arr = new int [5]; 在内存中会有连续的 5 个存储单元用于存放整数元素。这种连续的存储结构使得数组在访问元素时非常高效,通过索引可以直接计算出元素在内存中的位置。
- 链表是一种非连续的存储结构。它由一个个节点组成,每个节点包含数据域和指针域(在单向链表中是指向下一个节点的指针)。节点之间通过指针相连,形成一个链式结构。例如,在一个单向链表中,每个节点都存储了一个数据元素和一个指向下一个节点的指针,通过这个指针可以从一个节点找到下一个节点。
- 访问元素的效率:
- 对于数组,由于其连续的存储结构和通过索引直接访问的特点,访问元素的时间复杂度是 O (1)。也就是说,无论数组的大小是多少,访问指定索引位置的元素的时间是固定的。例如,要访问 arr [3],计算出元素位置后就可以直接获取,不需要遍历其他元素。
- 对于链表,要访问某个特定位置的元素,需要从链表的头节点(或尾节点,取决于链表的遍历方向)开始逐个节点遍历。如果要访问第 n 个节点,平均时间复杂度是 O (n)。因为在最坏的情况下,需要遍历 n 个节点才能找到目标节点。
- 插入和删除元素的效率:
- 在数组中插入和删除元素相对复杂。如果要在数组中间插入一个元素,需要将插入位置后面的元素依次向后移动一个位置,为新元素腾出空间。删除元素时,需要将删除位置后面的元素依次向前移动。在平均情况下,插入和删除元素的时间复杂度是 O (n),其中 n 是数组的长度。
- 在链表中插入和删除元素比较方便。只需要修改相关节点的指针即可。例如,在单向链表中插入一个节点,只需要将新节点插入到两个相邻节点之间,调整它们之间的指针关系。删除一个节点也类似,只需要调整被删除节点前后节点的指针,让它们直接相连。插入和删除元素的时间复杂度是 O (1),前提是已经找到了插入或删除的位置。
- 空间利用率:
- 数组在创建时需要指定大小,一旦创建,其大小就固定了。如果数组中实际存储的元素数量小于数组的大小,会造成一定的空间浪费。例如,创建一个长度为 10 的数组,但只存储了 5 个元素,就有一半的空间没有被利用。
- 链表的空间利用率相对较高,因为它是根据实际需要动态分配节点的。每个节点只需要存储数据和指针,不会有像数组那样因为大小固定而产生的闲置空间。不过,链表中的指针会占用一定的额外空间。
- 存储结构:
- linkedlist 能否 get (index):
- LinkedList 是 Java 集合框架中的一种链表实现。它可以使用 get (index) 方法来获取指定索引位置的元素。
- 但是,由于 LinkedList 的存储结构是链表,它获取元素的方式是从链表的头节点(或者尾节点,取决于实现方式)开始逐个节点遍历,直到找到指定索引位置的元素。所以,虽然它提供了 get (index) 方法,但这个方法的效率相对较低,时间复杂度是 O (n),其中 n 是索引值或者链表的长度(取决于从哪个方向开始遍历)。与数组的随机访问相比,LinkedList 的这种访问方式在性能上会差一些,特别是当索引值较大或者链表长度较长时。
讲讲栈和队列的区别并举例。
栈和队列都是数据结构,它们有明显的区别。
从结构特性来说,栈是一种后进先出(Last - In - First - Out,LIFO)的数据结构。就好像一个只有一个开口的容器,元素只能从这个开口进出。最后放入栈中的元素会最先被取出。例如,在一个浏览器的后退功能中,当用户访问多个网页时,每次访问一个新网页就相当于把这个网页的信息压入一个栈中。当用户点击后退按钮时,最后访问的网页信息(也就是最后压入栈中的元素)会最先被取出,浏览器会跳转到之前访问的最后一个网页。在代码实现上,栈通常有 push(压入元素)和 pop(弹出元素)等操作。
而队列是一种先进先出(First - In - First - Out,FIFO)的数据结构。可以把它想象成一个排队的场景,先进入队列的元素会先被处理。例如,在一个打印机任务队列中,用户发送的打印任务按照发送的顺序依次排列在队列中。打印机从队列头部开始获取任务并打印,先发送的打印任务会先被打印出来。队列通常有 enqueue(入队,即将元素添加到队尾)和 dequeue(出队,即从队头取出元素)等操作。
在应用场景方面,栈常用于函数调用、表达式求值等。在函数调用时,每调用一个新函数,就会把这个函数的相关信息(如参数、返回地址等)压入栈中,当函数执行结束时,这些信息从栈中弹出。表达式求值时,例如计算后缀表达式,操作数和运算符按照一定规则压入和弹出栈来得到结果。队列主要用于任务调度、消息传递等场景。像操作系统中的进程调度,就绪的进程可以按照一定的优先级或者到达时间等顺序排列在队列中等待 CPU 处理。
从存储和访问效率来看,栈在实现上比较简单,对于压入和弹出操作,时间复杂度通常为 O (1),因为只需要在栈顶进行操作。队列的入队和出队操作在合适的实现下(如循环队列)时间复杂度也可以达到 O (1)。但如果实现不当,例如使用简单的链表来实现队列,出队操作可能需要遍历链表找到队头元素,时间复杂度会变为 O (n),其中 n 是队列元素的个数。
子类能否重写父类的静态方法。
子类不能重写父类的静态方法。
在 Java 中,静态方法是属于类本身的,而不是类的实例。当调用一个静态方法时,是通过类名来调用的,而不是通过对象。例如,在父类中有一个静态方法 staticmethod (),在子类中定义一个同样签名(方法名、参数列表相同)的静态方法,这两个方法是相互独立的,它们只是在名称和参数上看起来相似。
重写的概念是基于多态性,是在运行时根据对象的实际类型来决定调用哪个方法。但是对于静态方法,在编译时就已经确定了调用的是哪个类的方法。比如,即使通过子类的对象来调用父类的静态方法,实际调用的仍然是父类的静态方法,而不是子类中定义的同签名的静态方法。
从内存角度看,静态方法在类加载时就已经被加载到方法区中,并且与类的实例无关。每个类都有自己独立的静态方法区域,所以子类无法改变父类静态方法的行为。不过,子类可以定义一个与父类静态方法同名的静态方法,这种情况更像是隐藏了父类的静态方法,而不是重写。
内部类能否访问外部类的 private 参数,外部类能否访问内部类的 private 参数。
内部类可以访问外部类的 private 参数。
这是因为内部类是外部类的一个成员,就像外部类的方法和属性一样。内部类和外部类之间有一种特殊的关联关系,内部类会持有一个外部类的引用。当内部类需要访问外部类的 private 参数时,它可以通过这个引用访问。例如,外部类有一个 private 的变量 privateVariable,内部类可以直接在自己的方法中访问这个变量,就好像在外部类的其他非 private 方法中访问一样。
然而,外部类不能直接访问内部类的 private 参数。因为内部类的 private 参数是属于内部类自身的私有成员,只有内部类内部的方法可以访问。如果外部类想要访问内部类的 private 参数,通常需要通过内部类提供的公共方法或者接口来间接访问。例如,内部类可以定义一个公共的方法来返回它的 private 参数,外部类可以通过创建内部类的实例,然后调用这个公共方法来获取内部类的 private 参数。
这种访问规则是基于面向对象的封装原则。内部类作为外部类的一个成员,它有权利访问外部类的所有成员,包括 private 成员,以方便实现内部类和外部类之间的紧密协作。而外部类不能随意访问内部类的 private 成员,是为了保证内部类的封装性,防止外部类对内部类内部实现细节的不当访问。
Java 是否能多继承,抽象类和接口的区别,抽象类是否至少要有一个抽象方法。
Java 不支持多继承,这是为了避免菱形继承问题(也称为死亡钻石问题)。菱形继承是指一个类同时继承了两个有共同父类的类,这样会导致在继承体系中,对于共同父类中的方法和属性的访问产生歧义。例如,如果类 C 继承自类 A 和类 B,而类 A 和类 B 都继承自类 D,当类 C 访问类 D 中的一个方法时,编译器无法确定是从类 A 还是类 B 继承过来的这个方法的版本,这会导致复杂的语义问题。
抽象类和接口有明显的区别。抽象类是一种不能被实例化的类,它可以包含抽象方法和非抽象方法。抽象方法是没有具体实现的方法,用 abstract 关键字修饰,子类必须重写这些抽象方法才能实例化。抽象类还可以有成员变量、构造方法等,它更像是一个普通类的抽象版本,用于提取一些公共的属性和方法,并且定义一些子类必须实现的抽象行为。
接口则是一种更加纯粹的抽象类型定义。接口中的方法默认都是抽象方法(在 Java 8 之前),并且接口中不能有实例变量(只有常量,默认是 public static final 修饰的)。接口主要用于定义一组规范或者契约,实现类必须实现接口中的所有方法,从而保证了实现类遵循接口所定义的行为规范。例如,在一个图形绘制系统中,可以定义一个接口 Drawable,里面有 draw 方法,所有实现了这个接口的类都必须实现 draw 方法,这样就保证了这些类都能够被绘制。
抽象类不是必须要有一个抽象方法。它可以没有抽象方法,但是这样的抽象类通常没有太大的实际意义,因为抽象类不能被实例化,而如果没有抽象方法,它和普通类的区别仅仅在于不能被实例化。不过,在一些设计场景下,可能会先定义一个抽象类,暂时没有抽象方法,后续在子类继承体系扩展时再添加抽象方法。
每个线程的 looper 唯一吗?为什么(ThreadLocalMap 原理)?
每个线程的 Looper 是唯一的。
在 Android 开发中,Looper 主要用于处理消息循环。它是和线程绑定的,一个线程只能有一个 Looper,原因主要和其设计目的以及 ThreadLocalMap 的原理有关。
ThreadLocalMap 是一个类似于 Map 的数据结构,它是 ThreadLocal 类的一个内部类。每个线程都有一个独立的 ThreadLocalMap,这个 Map 用于存储线程本地的数据。当为一个线程创建 Looper 时,实际上是将 Looper 对象存储在这个线程对应的 ThreadLocalMap 中。
从原理上讲,ThreadLocal 通过在每个线程内部维护一个独立的变量副本来实现线程隔离。当使用 ThreadLocal 来存储 Looper 时,每个线程在访问自己的 ThreadLocalMap 时,只会获取到自己线程中存储的 Looper。这样就保证了每个线程都有自己独立的 Looper,不会出现多个 Looper 在一个线程中混淆的情况。
例如,在主线程中,系统会自动创建一个 Looper,这个 Looper 用于处理主线程中的各种消息,如用户交互产生的事件消息等。当在其他线程中,如果需要创建 Looper,这个新的 Looper 也只会和这个线程本身相关联,用于处理这个线程中的消息循环,不会影响到其他线程的 Looper 或者被其他线程的 Looper 所影响。这种设计使得每个线程能够独立地处理自己的消息,提高了系统的可靠性和可维护性。
Handler 是用来做啥的,每个线程都能有自己的 Handler 吗,Handler 运行过程。
Handler 主要用于在 Android 中实现线程间的通信,特别是在子线程和主线程之间传递消息。
在 Android 中,UI 操作必须在主线程进行,而耗时操作通常放在子线程。当子线程完成任务后,需要更新 UI 时,就可以使用 Handler。例如,在一个网络请求线程中获取到数据后,通过 Handler 将数据发送给主线程,然后在主线程中更新 UI 显示数据。
每个线程理论上都可以有自己的 Handler。不过,要使用 Handler,线程需要有一个与之关联的 Looper。主线程默认有 Looper,所以可以很方便地创建和使用 Handler。对于子线程,如果想要使用 Handler,需要先为线程创建 Looper。
Handler 的运行过程主要涉及消息的发送和处理。首先,通过 Handler 的 sendMessage 或 post 等方法来发送消息。这些消息会被添加到与 Handler 关联的 MessageQueue 中。MessageQueue 是一个消息队列,它按照消息的发送时间等顺序存储消息。
当与 MessageQueue 关联的 Looper 开启循环后,它会不断从 MessageQueue 中取出消息。Looper 会检查消息的类型等信息,然后将消息分发给对应的 Handler 进行处理。Handler 通过重写 handleMessage 方法来定义如何处理收到的消息。例如,在 handleMessage 方法中,可以根据消息的内容来更新 UI 组件,如设置文本内容、改变视图的可见性等。整个过程就像是一个消息传递和处理的流水线,使得不同线程之间可以有序地进行通信。
synchronized 修饰实例方法和静态方法区别,synchronized 修饰静态方法和普通方法时的区别,synchronized 可重入吗,类锁、对象锁有区别吗。
当 synchronized 修饰实例方法时,它锁定的是当前对象实例。也就是说,当一个线程访问某个对象的 synchronized 实例方法时,其他线程如果也想访问这个对象的同一个 synchronized 实例方法,就会被阻塞。例如,有一个类的多个实例,每个实例的 synchronized 实例方法可以被不同的线程同时访问,只要这些线程访问的不是同一个实例。
而 synchronized 修饰静态方法时,它锁定的是这个类的 Class 对象。因为静态方法是属于类的,而不是属于某个实例。这意味着,当一个线程访问一个类的 synchronized 静态方法时,其他线程访问这个类的任何一个 synchronized 静态方法都会被阻塞,不管是通过哪个实例或者类本身来访问。
synchronized 是可重入的。这意味着当一个线程已经获得了一个对象的锁(通过 synchronized 方法或者代码块),如果这个方法内部又调用了同一个对象的另一个 synchronized 方法或者代码块,这个线程可以直接进入,而不会被自己持有的锁阻塞。例如,一个类中有两个 synchronized 方法 A 和 B,在方法 A 中调用方法 B,同一个线程可以顺利执行,不会出现死锁情况。
类锁和对象锁是有区别的。对象锁是针对对象实例的,不同的对象实例有自己独立的对象锁。而类锁是针对整个类的,所有对这个类的静态 synchronized 方法的访问共享同一个类锁。对象锁用于控制对对象实例的并发访问,类锁用于控制对类的静态资源或者共享行为的并发访问。
volatile 的作用是什么,禁止指令重排序是怎么实现的,为什么需要禁止指令重排序。
volatile 的主要作用是保证变量的可见性和禁止指令重排序。
对于变量的可见性,在多线程环境下,当一个线程修改了一个 volatile 变量的值,这个新的值会立即对其他线程可见。例如,在两个线程共享一个 volatile 变量时,一个线程修改了这个变量,另一个线程读取这个变量时,会获取到最新的值,而不会使用缓存中的旧值。
关于禁止指令重排序,在编译器和处理器为了优化性能可能会对指令进行重排序。但是在某些情况下,这种重排序可能会导致程序出现错误。volatile 通过内存屏障(Memory Barrier)来禁止指令重排序。内存屏障是一种硬件级或者编译器级的指令,它确保在屏障之前的操作和之后的操作按照一定的顺序执行。
之所以需要禁止指令重排序,是因为在多线程程序中,指令重排序可能会导致程序逻辑出现问题。例如,一个线程先写了一个变量,然后再写另一个变量,但是由于指令重排序,在另一个线程看来,可能后写的变量先被更新了,这就会导致程序出现不符合预期的结果。通过禁止指令重排序,可以保证程序在多线程环境下的正确性。
如何对象判断是否已死,Java finalize 过程。
判断对象是否已死主要有两种方法:引用计数法和可达性分析算法。
引用计数法是比较简单的方式,它通过给每个对象添加一个引用计数器。当有一个地方引用这个对象时,计数器就加 1,当引用失效时,计数器就减 1。当计数器的值为 0 时,就认为这个对象是可以回收的。不过这种方法有缺陷,无法解决循环引用的问题。
可达性分析算法是主流的判断方法。它以 GCRoots 为起始点,通过一系列称为 “引用链” 的对象引用关系来搜索。如果一个对象到任何一个 GCRoots 都没有引用链相连,那么这个对象就是不可达的,被认为是可以回收的。
Java 中的 finalize 过程是当一个对象即将被垃圾回收时,这个对象的 finalize 方法会被调用。这个方法是 Object 类中的一个方法,子类可以重写这个方法。在 finalize 方法中,可以进行一些资源清理等操作。不过,finalize 方法的调用时机是不确定的,并且在现代的 Java 编程中,不推荐依赖 finalize 方法来进行资源清理等操作。因为垃圾回收的过程是由 JVM 的垃圾回收器控制的,它可能不会及时地调用 finalize 方法,甚至可能根本不调用。
可以不断启动同个线程的 start 方法吗?
不可以不断启动同一个线程的 start 方法。
当一个线程的 start 方法被调用后,这个线程就会进入就绪状态,然后由操作系统的调度器来决定何时运行这个线程。一旦线程开始运行,再次调用 start 方法会抛出 IllegalThreadStateException 异常。
这是因为线程的 start 方法内部有一系列的状态检查和初始化操作,这些操作在一个线程的生命周期中只应该执行一次。如果允许重复启动,会导致线程状态混乱,并且可能会引发不可预测的问题,比如多个线程同时执行同一个线程的任务逻辑,或者线程的状态在不适当的时候被重置等。
在实际应用中,如果需要多次执行相同的任务,可以考虑将任务逻辑封装在一个 Runnable 接口的实现中,然后创建一个新的线程来执行这个 Runnable,或者使用线程池来管理和复用线程,以合理地执行多个任务。
讲讲 ANR 是什么,怎样避免发生。
ANR(Application Not Responding)是指应用程序无响应。在 Android 系统中,如果应用的主线程在一段时间内无法处理用户输入事件或者广播接收器等耗时太长,系统就会判定该应用出现 ANR。
例如,当用户点击一个按钮,系统会发送一个输入事件到应用的主线程,如果主线程因为长时间执行一个复杂的网络请求或者大量的数据计算而无法及时处理这个按钮点击事件,在超过一定时间(通常是 5 秒左右,对于广播接收器是 10 秒左右)后,就会触发 ANR。
要避免 ANR 的发生,首先要避免在主线程进行耗时操作。对于网络请求,应该使用异步方式。比如使用 OkHttp 库,通过在子线程中发起请求,在回调函数中处理响应数据,而不是在主线程中直接调用网络请求方法。
对于复杂的数据库操作,如大量数据的插入或者查询,也不能在主线程进行。可以使用 SQLiteOpenHelper 的异步操作方法或者将数据库操作封装在一个异步任务(AsyncTask)中。
另外,对于可能会耗时的广播接收器,其 onReceive 方法中的操作应该尽量简洁。如果需要执行复杂的任务,可以启动一个服务(Service)来处理,避免让广播接收器的执行时间过长。
同时,在多线程环境下,要注意线程间的同步。如果多个线程同时访问和修改主线程相关的资源,可能会导致主线程等待这些操作完成而出现卡顿甚至 ANR。可以使用合适的锁机制(如 synchronized 关键字)来协调线程间的访问,确保主线程能够顺畅地运行。
讲讲 get 和 post 请求区别。
GET 请求和 POST 请求是 HTTP 协议中的两种常见请求方式。
从语义上看,GET 请求主要用于从服务器获取资源。它就像是向服务器询问一个问题,服务器根据请求的 URL 中的参数等信息返回对应的资源。例如,在一个新闻应用中,通过 GET 请求获取新闻列表,请求的 URL 可能包含页码、分类等参数,服务器根据这些参数返回相应的新闻列表数据。
POST 请求主要用于向服务器提交数据,让服务器对数据进行处理。比如在用户注册功能中,将用户填写的用户名、密码等信息通过 POST 请求发送到服务器,服务器接收到这些数据后,将其保存到数据库中。
在数据传输方面,GET 请求的数据是通过 URL 传递的,这些数据会显示在浏览器的地址栏中,并且对数据长度有一定的限制(因为 URL 长度是有限制的)。而 POST 请求的数据是放在请求体(Request Body)中的,不会在 URL 中显示,并且对数据大小的限制相对较松。
从安全性角度考虑,由于 GET 请求的数据在 URL 中可见,所以相对不太安全,对于一些敏感信息(如密码)不适合使用 GET 请求传输。POST 请求因为数据在请求体中,相对更安全一些。
在服务器端处理上,GET 请求通常被认为是幂等的,这意味着多次相同的 GET 请求应该返回相同的结果。POST 请求一般不是幂等的,因为每次提交的数据可能会导致服务器状态发生不同的变化。
https 响应报文组成,状态码 500 和 503 的代表啥。
HTTPS 响应报文主要由状态行、响应头部、空行和响应正文组成。
状态行包含协议版本、状态码和状态消息。例如,“HTTP/1.1 200 OK”,这里的 “HTTP/1.1” 是协议版本,“200” 是状态码,“OK” 是状态消息。
响应头部包含了关于响应的各种信息,如服务器类型、内容类型、内容长度等。比如 “Content - Type: application/json” 表示响应正文的内容类型是 JSON 格式。
空行用于分隔响应头部和响应正文。
响应正文是服务器返回的实际内容,比如在请求一个网页时,响应正文就是网页的 HTML 内容;请求一个 API 获取数据时,响应正文可能是 JSON 或者 XML 格式的数据。
状态码 500 代表服务器内部错误。这通常是因为服务器在执行请求的过程中遇到了无法处理的错误,可能是代码中的逻辑错误、数据库操作异常等。例如,在一个 Web 应用中,服务器端的 Java 代码在处理一个复杂的业务逻辑时抛出了未捕获的异常,就会返回 500 状态码。
状态码 503 代表服务不可用。这一般是由于服务器暂时无法处理请求,可能是因为服务器过载、维护或者其他临时的故障。比如服务器正在进行系统升级,此时收到请求就会返回 503 状态码,告知客户端服务暂时无法提供。
数据库索引作用,什么时候加索引,原理。
数据库索引的主要作用是提高数据查询的速度。
假设数据库中有一个包含大量用户记录的表,当需要根据用户的姓名来查找某个用户时,如果没有索引,数据库可能需要遍历整个表中的每一条记录来查找匹配的姓名,这在数据量很大时会非常耗时。而有了索引,就相当于有了一个目录,数据库可以直接通过索引快速定位到包含目标姓名的记录位置,大大减少了查询时间。
在以下情况可以考虑添加索引。一是在经常用于查询条件的列上添加索引。例如,在一个电商应用的订单表中,经常会根据订单号来查询订单信息,那么在订单号列上添加索引可以提高查询效率。
二是在用于连接(JOIN)操作的列上添加索引。比如在用户表和订单表通过用户 ID 进行连接查询时,在用户表和订单表的用户 ID 列上添加索引可以加快连接查询的速度。
索引的原理主要基于数据结构。常见的索引数据结构是 B - Tree(B 树)和 B + Tree(B + 树)。以 B + Tree 为例,它是一种平衡的多路查找树。索引会将列的值按照一定的顺序存储在树的节点中。在查询时,通过比较节点中的值,可以快速定位到目标数据所在的位置。例如,在一个 B + Tree 索引中,根节点包含了一些索引值的范围,通过比较查询值和根节点的范围,可以决定进入哪个子节点继续查找,直到找到目标数据所在的叶子节点。叶子节点存储了实际的数据记录指针或者数据本身,这样可以快速获取到查询的数据。
数据库数据脏读情况。
脏读是数据库事务中的一个问题,它是指一个事务读取了另一个未提交事务修改的数据。
例如,在一个银行转账系统中,事务 A 正在修改账户 A 的余额,将 1000 元转给账户 B,但这个事务还没有提交。此时,事务 B 开始查询账户 A 的余额,读取到了事务 A 修改后的余额,这就是脏读。
脏读情况通常发生在多个事务并发执行,并且没有正确的隔离级别设置的情况下。数据库的事务隔离级别用于控制不同事务之间的相互影响程度。
如果设置的隔离级别较低,如读未提交(Read Uncommitted),就很容易出现脏读。在这种隔离级别下,一个事务可以读取其他事务未提交的数据。
在实际应用中,为了避免脏读,应该根据业务需求合理设置事务隔离级别。例如,在大多数业务场景下,可以使用可重复读(Repeatable Read)或者串行化(Serializable)隔离级别。这些隔离级别通过对数据加锁或者使用版本控制等方式,确保一个事务在读取数据时,不会受到其他未提交事务的干扰,从而避免脏读的发生。同时,在开发过程中,也要注意事务的开启和结束时间,避免长时间的事务导致数据不一致和脏读的风险。
讲讲 S 锁和 X 锁区别。
S 锁(共享锁)和 X 锁(排他锁)是数据库中用于控制并发访问的两种锁机制。
S 锁主要用于多个事务对同一数据进行读取操作。当一个事务对数据加上 S 锁后,其他事务也可以对该数据加 S 锁来进行读取。例如,在一个图书馆管理系统中,多个读者可以同时查询某一本书的信息,此时就可以对这本书的相关数据记录加上 S 锁。这种共享的读取操作不会相互干扰,多个事务可以同时获取相同的数据副本进行查看。
X 锁则是用于对数据进行排他性的操作,包括写入或者修改等操作。当一个事务对数据加上 X 锁后,其他事务既不能对该数据加 S 锁也不能加 X 锁,直到持有 X 锁的事务释放锁为止。比如,在图书馆管理系统中,当一个管理员要修改某一本书的库存信息时,需要对这本书的库存数据记录加上 X 锁,这样其他管理员或者读者就不能同时修改或读取这个正在被修改的库存数据,确保数据的一致性和完整性。
从并发性能角度看,S 锁允许多个事务同时读取数据,能够提高并发读取的效率。而 X 锁因为其排他性,在一个事务持有 X 锁期间会阻塞其他事务对同一数据的访问,可能会影响并发性能,但这是为了保证数据在修改过程中的准确性。在数据库事务处理中,合理地使用 S 锁和 X 锁可以有效地平衡数据的并发访问和数据的准确性之间的关系。
讲讲 TCP UDP 区别。
TCP(传输控制协议)和 UDP(用户数据报协议)是两种不同的传输层协议,它们有诸多区别。
从连接方式上看,TCP 是面向连接的协议。这意味着在数据传输之前,通信双方需要先建立连接,就像打电话一样,先拨通对方的号码建立通话线路。在传输结束后,还需要通过一定的步骤来关闭连接。而 UDP 是无连接的协议,它就像发送短信,不需要事先建立连接,直接发送数据报。
在可靠性方面,TCP 提供可靠的数据传输。它通过序列号、确认应答、重传机制等来保证数据的完整性和顺序性。例如,发送方发送的数据如果在一定时间内没有收到接收方的确认应答,就会重新发送数据。UDP 则不提供这些可靠性保证,数据报可能会丢失、重复或者乱序,接收方需要自己处理这些情况。
对于传输效率,UDP 的传输效率通常比 TCP 高。因为 TCP 需要进行大量的连接建立、维护和数据确认等操作,这些操作会占用一定的时间和带宽。UDP 由于没有这些复杂的机制,所以数据传输更加简洁快速,适合对实时性要求高但对数据完整性要求相对较低的场景。
在应用场景上,TCP 适合用于对数据准确性和完整性要求高的场景,如文件传输、电子邮件等。UDP 则常用于对实时性要求高的场景,如视频直播、在线游戏等。
为什么需要三次握手和四次挥手,三次挥手不行吗,两次挥手呢(服务器接收到客户端的 FIN 报文,立马将回复客户端 ACK 和自己的 FIN 报文一并发给客户端可以吗?)
三次握手主要是为了确保通信双方都具有发送和接收数据的能力。
第一次握手,客户端发送 SYN 报文给服务器,这表明客户端有发送数据的意愿,并且告知服务器自己的初始序列号。第二次握手,服务器收到客户端的 SYN 报文后,回复一个 SYN + ACK 报文,这个报文一方面确认收到了客户端的请求,另一方面也告知客户端自己的初始序列号。第三次握手,客户端收到服务器的 SYN + ACK 报文后,再发送一个 ACK 报文给服务器,这样服务器就知道客户端已经收到了自己的序列号等信息。通过这三次握手,双方都确认了对方能够发送和接收数据,从而建立起可靠的连接。
四次挥手是因为 TCP 是全双工通信,即双方都可以同时发送和接收数据。当客户端想要关闭连接时,发送 FIN 报文,表示自己不再发送数据,但还可以接收数据。服务器收到 FIN 报文后,回复一个 ACK 报文,表示已经收到客户端的关闭请求。此时,服务器可能还有数据要发送给客户端,所以等服务器发送完自己的数据后,再发送一个 FIN 报文给客户端,客户端收到后回复一个 ACK 报文,这样连接才完全关闭。
如果是三次挥手,当客户端发送 FIN 报文后,服务器直接发送 FIN + ACK 报文,可能会导致客户端还有数据没收到就被关闭连接,因为客户端以为服务器已经没有数据要发送了。
如果是两次挥手,会存在很多问题。例如,客户端发送 FIN 报文后直接关闭连接,服务器可能还没来得及接收和处理这个 FIN 报文,或者还没发送完自己的数据,这样会导致数据丢失和连接关闭不彻底的情况。
TCP 属于哪一层,TCP 的上一层是哪一层。
TCP 属于传输层。在网络体系结构中,它的主要作用是提供端到端的可靠通信服务。
TCP 的上一层是应用层。应用层协议依靠 TCP 来实现可靠的数据传输。例如,HTTP(超文本传输协议)用于在 Web 浏览器和 Web 服务器之间传输网页数据,它就构建在 TCP 之上。当浏览器请求一个网页时,HTTP 协议将请求数据交给 TCP,TCP 负责将这些数据分割成合适的数据包,添加序列号、确认应答等信息,通过网络层和链路层发送到服务器。服务器接收到数据后,TCP 再将数据包重新组装成完整的 HTTP 请求并交给服务器的 HTTP 服务进行处理。
这样的分层结构使得不同的协议可以专注于自己的功能。TCP 负责传输的可靠性和流量控制等,而应用层协议则专注于具体的应用场景,如网页浏览、文件传输、电子邮件等,通过使用 TCP 的服务来实现高效、可靠的应用通信。
应用层常见的协议。
应用层有许多常见的协议。
首先是 HTTP(超文本传输协议),它主要用于在 Web 浏览器和 Web 服务器之间传输网页数据。当用户在浏览器中输入一个网址并回车时,浏览器就会通过 HTTP 协议向服务器发送请求,服务器根据请求返回网页的 HTML 文件、图片、脚本等资源。例如,一个新闻网站,用户通过浏览器访问新闻页面,浏览器使用 HTTP 协议获取新闻内容的文本和相关的图片,然后在浏览器中展示出来。
还有 FTP(文件传输协议),它用于在网络上进行文件的上传和下载。用户可以使用 FTP 客户端软件连接到 FTP 服务器,通过 FTP 协议进行文件的传输操作。比如,在一个公司内部网络中,员工可以通过 FTP 将本地文件上传到公司的文件服务器,或者从服务器下载需要的文件。
SMTP(简单邮件传输协议)是用于发送电子邮件的协议。当用户在邮件客户端编写好邮件并点击发送时,邮件客户端通过 SMTP 协议将邮件发送到邮件服务器,邮件服务器再根据收件人的地址将邮件转发到目标邮件服务器,最终到达收件人的邮箱。
另外,DNS(域名系统)协议也很重要。它用于将域名转换为 IP 地址。因为人们更容易记住域名,而计算机在网络上是通过 IP 地址来通信的。当用户在浏览器中输入一个网址时,首先会通过 DNS 协议查询对应的 IP 地址,然后才能通过 HTTP 等协议与服务器进行通信。
http 与 https 的区别,https 怎么进行的加密,对称加密和非对称加密的方式,你知道的非对称和对称加密有哪些。
HTTP 是超文本传输协议,它以明文方式传输数据。而 HTTPS 是在 HTTP 的基础上加入 SSL/TLS 加密协议,通过加密来保障数据传输的安全性。
在数据传输安全性方面,HTTP 的数据在传输过程中很容易被中间人窃取和篡改,因为它没有加密措施。HTTPS 则对数据进行加密,使得数据在网络传输过程中是密文形式,即使被中间人获取,也很难解读其中的内容。从端口使用来说,HTTP 一般使用 80 端口,HTTPS 使用 443 端口。并且,在浏览器显示上,使用 HTTPS 的网址通常会有安全锁标识,让用户直观地知道连接是安全的。
HTTPS 的加密过程涉及多种加密技术。首先是通过非对称加密进行密钥交换。在客户端和服务器建立连接初期,服务器会把包含公钥的证书发送给客户端。客户端验证证书的合法性后,利用服务器的公钥来加密一个随机生成的对称密钥。然后这个加密后的对称密钥被发送给服务器,服务器用自己的私钥解密得到对称密钥。
之后,双方使用这个对称密钥进行数据加密传输。对称加密是指加密和解密使用相同的密钥。例如,常见的 AES(高级加密标准)加密算法就属于对称加密。它的加密方式是将明文数据通过特定的算法和密钥转换为密文,解密时使用相同的密钥将密文还原为明文。
非对称加密则是加密和解密使用不同的密钥,即公钥和私钥。公钥可以公开,私钥需要保密。如 RSA 算法,用公钥加密的数据只能用对应的私钥解密,反之亦然。除了 RSA 外,还有 DSA(数字签名算法)等非对称加密算法,对称加密除 AES 外,还有 DES(数据加密标准)等。
room 是什么类型的数据库,room 是怎么实现的。
Room 是一个 Android Jetpack 库,用于在 Android 应用中提供一个抽象层来访问 SQLite 数据库。它是一种对象关系映射(ORM)数据库。
Room 的实现主要是通过几个核心组件。首先是实体(Entity),它代表数据库中的一张表。可以通过在 Java 或 Kotlin 类上添加注解来定义实体,比如使用 @Entity 注解一个类,这个类的属性就对应着表中的列。每个实体对象就代表表中的一行数据。
其次是数据访问对象(DAO,Data Access Object)。DAO 用于定义访问数据库的方法,包括插入、查询、更新和删除等操作。通过在接口上添加注解来定义这些方法。例如,使用 @Insert 注解一个方法用于插入数据,使用 @Query 注解用于定义查询语句。
最后是数据库(Database)类,它是一个抽象类,用于定义数据库的整体架构。通过使用 @Database 注解来指定数据库的版本、包含的实体等信息。在这个类中,可以获取 DAO 的实例,从而在应用的其他部分使用 DAO 来操作数据库。
在底层,Room 会在编译时根据这些注解生成相应的 SQL 语句和数据库访问代码。在运行时,它会与 SQLite 数据库进行交互。当应用调用 DAO 中的方法时,Room 会将这些方法调用转换为对应的 SQLite 操作,对数据库进行增删改查,然后将结果(如果有)返回给应用。这样就为开发者提供了一种方便、类型安全的方式来操作 SQLite 数据库,避免了直接编写大量的 SQL 语句。
有网状态下的缓存和无网状态下的缓存一样吗,缓存是自己做的还是 okhttp 做的,用户可以自定义多个拦截器吗,为什么 okHttp 里面用到责任链模式。
在有网和无网状态下,缓存的目的相似但也有区别。有网时,缓存可以用于减少重复请求,提高响应速度,同时也可以在网络不稳定时提供备用数据。无网时,缓存主要是为了让应用能够在没有网络连接的情况下展示一些之前获取的数据。
在实现方式上,OkHttp 有自己的缓存机制。它可以根据请求头中的缓存控制信息(如 Cache - Control)和响应头中的信息来判断是否使用缓存。不过,开发者也可以自己实现缓存策略。
缓存可以是 OkHttp 做的,它提供了默认的缓存机制。OkHttp 会根据一定的规则存储和读取缓存数据。但开发者也可以自己做缓存,比如在应用层面对某些重要数据进行额外的存储和管理,以满足特定的需求。
用户可以自定义多个拦截器。拦截器在 OkHttp 中用于在请求和响应的过程中插入自定义的逻辑。例如,可以有一个拦截器用于添加统一的请求头,另一个拦截器用于对响应数据进行解密等操作。
OkHttp 里面用到责任链模式是为了灵活地处理请求和响应。责任链模式允许将多个处理请求和响应的环节连接成一个链。每个拦截器就像是链中的一个环节,当一个请求经过时,会依次通过各个拦截器进行处理。这样可以方便地添加、删除或修改处理环节,而不影响其他部分的功能。例如,在处理一个网络请求时,可以先经过一个日志记录拦截器,然后是缓存检查拦截器,再是网络请求拦截器等,每个拦截器都可以对请求或响应进行自己的操作,从而实现复杂的网络处理逻辑。
用过 okhttp 的原理实现,它的原理和拦截器了解吗(原理讲了下,拦截器不是太懂没讲)。
OkHttp 的原理主要是围绕着高效地处理网络请求和响应展开的。
它内部有一个连接池,用于管理和复用网络连接。当发起一个新的请求时,OkHttp 首先会检查连接池,看是否有可以复用的连接。如果有合适的连接,就直接使用这个连接来发送请求,减少了重新建立连接的时间和资源消耗。
在请求发送过程中,OkHttp 会将请求进行封装,包括设置请求头、请求方法(如 GET、POST 等)和请求体(如果是 POST 等有请求体的请求)。然后通过底层的网络协议(如 HTTP/2 或 HTTP/1.1)将请求发送到服务器。
对于拦截器,它是 OkHttp 中一个非常重要的概念。拦截器可以看作是一个处理请求和响应的钩子。在请求发出之前,拦截器可以对请求进行修改,比如添加请求头、修改请求路径等。在响应返回时,拦截器可以对响应进行处理,如检查响应状态码、解析响应数据等。
例如,有一个用于添加认证信息的拦截器。当请求经过这个拦截器时,它会检查请求是否需要认证,如果需要,就会在请求头中添加认证相关的字段,如 Token 等。另一个用于日志记录的拦截器,可以在请求发出前记录请求的详细信息,在响应返回后记录响应的详细信息,这样可以方便开发者对网络请求进行调试和监控。
多个拦截器可以组成一个拦截链。请求按照拦截链的顺序依次经过各个拦截器,每个拦截器都可以对请求进行操作,然后传递给下一个拦截器。响应返回时,也是按照相反的顺序经过各个拦截器,这样就可以实现对请求和响应的多层次、多环节的处理。
为什么要用 MVVM,数据变更 UI 自动更新怎么实现的,用的 DataBinding 吗。
使用 MVVM(Model - View - ViewModel)架构主要有几个优势。首先,它使得数据和视图的分离更加清晰。Model 层负责数据的存储和获取,View 层专注于用户界面的展示,ViewModel 层作为中间层,将 Model 中的数据转换为 View 可以使用的形式。这种分层结构让代码更易于维护和扩展。
例如,在一个复杂的列表展示应用中,Model 层可以是数据库操作或者网络请求获取数据,ViewModel 层可以对这些数据进行加工,如排序、过滤等,然后提供给 View 层进行展示。
对于数据变更 UI 自动更新,主要是通过数据绑定(Data Binding)和观察者模式来实现的。在 ViewModel 层,数据通常是被包装成可观察的对象,如 LiveData。LiveData 是一种可以感知生命周期的数据持有类。当数据发生变化时,它会通知所有订阅它的观察者。
View 层通过绑定机制(如 DataBinding 或者手动订阅)与 ViewModel 中的 LiveData 关联。当 LiveData 中的数据发生变化时,View 层能够收到通知并自动更新 UI。例如,在一个显示用户信息的界面中,用户的姓名存储在 ViewModel 的 LiveData 中,当姓名被修改后,LiveData 会通知与之绑定的 View,如 TextView,TextView 就会自动更新显示的姓名内容。
可以使用 DataBinding 来实现数据绑定,它是 Android Jetpack 的一个组件。通过在布局文件中使用特定的语法,可以直接将布局中的视图与 ViewModel 中的数据进行绑定。这样在数据发生变化时,布局中的视图可以根据绑定的规则自动更新。不过,也可以不使用 DataBinding,通过手动实现观察者模式来达到类似的效果,只是代码会相对复杂一些。
什么是观察者模式,有什么好处。
观察者模式是一种软件设计模式。它定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。当主题对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新自己的状态。
以一个新闻发布系统为例,新闻发布者就是主题对象,订阅新闻的用户就是观察者。新闻发布者发布新的新闻(状态改变),所有订阅的用户(观察者)就会收到通知,从而可以更新自己看到的新闻内容。
观察者模式有诸多好处。首先是解耦性高。主题对象和观察者对象之间是松耦合的关系。主题对象只需要知道有哪些观察者对象需要通知,而不需要了解观察者对象的具体细节,比如它们如何更新自己的状态。观察者对象也只需要实现更新自己状态的方法,不需要关心主题对象的内部状态是如何改变的。
其次,它支持动态添加和删除观察者。在系统运行过程中,可以很方便地增加或减少观察者。例如,在新闻发布系统中,用户可以随时订阅或取消订阅新闻,而不会对新闻发布者的发布功能产生影响。
另外,它符合开闭原则。当需要增加新的观察者或者改变主题对象的状态通知逻辑时,不需要修改原有的主题对象和观察者对象的代码,只需要实现新的观察者类或者修改通知的逻辑部分即可。这种灵活性使得系统在面对需求变化时更容易维护和扩展。
什么情况下用责任链模式?哪里遇到过?有什么好处?怎么实现的责任链模式。Android 中的设计模式。
责任链模式适用于处理请求的对象有多个,并且这些对象可以按照一定的顺序依次处理请求的情况。当一个请求的处理可能需要经过多个环节,每个环节都有机会处理或者传递请求时,就可以使用责任链模式。
在 Android 的事件分发系统中可以遇到责任链模式。例如,从屏幕触摸事件的分发来看,事件会从 Activity 依次传递到 ViewGroup,再到 View。每个层次都有机会处理事件,如果当前层次不处理,就会传递给下一个层次。
责任链模式的好处有很多。一是可以灵活地增加或者删除处理环节。比如在一个网络请求处理系统中,可以很容易地添加一个新的拦截器(处理环节)来对请求进行额外的处理,如日志记录、权限验证等。二是将请求的发送者和接收者解耦。发送者不需要知道具体是哪个环节处理了请求,只需要将请求发送到责任链的开头即可。
实现责任链模式主要包括几个部分。首先要定义一个处理请求的接口,每个具体的处理者都要实现这个接口。这个接口通常包含一个处理请求的方法和一个指向下一个处理者的引用。然后创建具体的处理者类,在这些类中实现处理请求的方法。在处理请求时,每个处理者可以根据自己的规则决定是处理请求还是将请求传递给下一个处理者。在客户端,只需要将请求发送到责任链的第一个处理者,就可以启动整个处理流程。
在 Android 中,除了上面提到的责任链模式,还有单例模式(例如在获取系统服务时,如 LayoutInflater)、工厂模式(用于创建视图等)、观察者模式(如 LiveData 的实现)等多种设计模式。这些设计模式有助于构建更加灵活、可维护和可扩展的 Android 应用。
事件分发的原理,onsetclicklistenr 和 ontouchlistener 会冲突吗。
在 Android 中,事件分发主要涉及三个重要的方法:dispatchTouchEvent、onInterceptTouchEvent 和 onTouchEvent。事件分发是从 Activity 开始的,当有触摸事件发生时,Activity 的 dispatchTouchEvent 方法首先被调用,它会将事件分发给内部的 ViewGroup。
ViewGroup 的 dispatchTouchEvent 方法接着会被调用,它可以选择调用 onInterceptTouchEvent 方法来判断是否拦截这个事件。如果拦截,事件就不会再传递给子 View,而是由 ViewGroup 自己的 onTouchEvent 方法来处理。如果不拦截,事件会传递给子 View,子 View 的 dispatchTouchEvent 方法会被调用,如此层层传递,直到有 View 处理这个事件或者事件被传递到最底层的 View。
对于 onSetClickListener 和 onTouchListener,它们可能会产生冲突。onTouchListener 的优先级更高。当一个 View 同时设置了 onTouchListener 和 onClickListener 时,如果 onTouchListener 中的 onTouch 方法返回 true,那么 onClickListener 中的 onClick 方法将不会被执行。因为 onTouch 方法返回 true 表示这个触摸事件已经被处理,不会再触发后续的点击事件处理逻辑。
只有当 onTouch 方法返回 false 时,事件才会继续传递,有可能触发 onClickListener 中的 onClick 方法。例如,在一个自定义的 View 中,如果在 onTouchListener 中实现了一些复杂的触摸拖动效果,并且希望在拖动结束后触发点击事件,就需要在适当的时候返回 false,让点击事件有机会被处理。
了解过滑动冲突吗,事件分发机制。
滑动冲突是在 Android 开发中比较常见的问题。当一个 ViewGroup 包含可滑动的子 View,并且 ViewGroup 自身也可以滑动时,就可能出现滑动冲突。例如,在一个包含 ListView 的 ScrollView 中,当用户在 ListView 上滑动时,是应该让 ListView 滑动还是让 ScrollView 滑动,这就产生了滑动冲突。
事件分发机制在解决滑动冲突中起着关键作用。从事件分发的角度来看,触摸事件首先会被传递到外层的 ViewGroup。如果外层 ViewGroup 的 onInterceptTouchEvent 方法拦截了事件,那么事件就由这个 ViewGroup 处理,不会传递给子 View。但如果不拦截,事件会传递给子 View,由子 View 的 dispatchTouchEvent 方法来处理。
在处理滑动冲突时,一种常见的方法是根据滑动的方向或者距离来判断。例如,如果用户的滑动方向主要是垂直方向,就让外层的 ScrollView 处理事件;如果主要是水平方向,就让内层的 ListView 处理事件。这可以在 ViewGroup 的 onInterceptTouchEvent 方法中通过比较触摸点的移动距离在水平和垂直方向的差异来实现。
另一种方法是根据业务场景来决定。比如在某些情况下,总是优先让某个特定的 View 处理事件,而不管滑动方向等因素。这需要在事件分发的各个环节中,通过合理地设置拦截和处理逻辑来实现。
RxJava 用过了哪些操作符。
RxJava 有许多实用的操作符。
map 操作符是比较常用的。它用于对上游发射的数据进行转换。例如,有一个 Observable 发射整数序列,通过 map 操作符可以将每个整数进行平方运算后再发射出去。假设上游发射的数据是 1、2、3,经过 map 操作符后,下游接收到的数据就是 1、4、9。
filter 操作符用于过滤数据。它根据给定的条件来筛选上游发射的数据。比如,在一个发射整数的 Observable 中,使用 filter 操作符只让偶数通过,那么奇数就会被过滤掉。如果上游发射的数据是 1、2、3、4,经过 filter 操作符后,下游接收到的数据就是 2、4。
flatMap 操作符用于将一个发射多个数据的 Observable 转换为多个单独的 Observable,然后将这些单独的 Observable 发射的数据合并成一个新的 Observable。例如,有一个 Observable 发射多个列表,通过 flatMap 操作符可以将每个列表中的元素逐个发射出来,而不是发射列表本身。
subscribeOn 操作符用于指定观察者订阅的线程,而 observeOn 操作符用于指定观察者接收数据的线程。这两个操作符在处理多线程场景时非常有用。例如,在进行网络请求时,可以使用 subscribeOn 将网络请求放在一个单独的线程中进行,然后使用 observeOn 将结果返回到主线程来更新 UI。
还有 merge 操作符,它可以将多个 Observable 合并为一个 Observable,将它们发射的数据按照顺序依次发射出来。例如,有两个分别发射整数序列的 Observable,通过 merge 操作符可以将它们的整数序列合并成一个新的序列发射出去。
livedata 粘性事件。
LiveData 是 Android Jetpack 中的一个重要组件,用于在 ViewModel 和 View 层之间进行数据传递,并能感知生命周期。而 LiveData 的粘性事件特性是指当一个观察者订阅 LiveData 时,如果 LiveData 已经有了一个值,那么这个观察者会立即接收到这个已有的值,就好像这个值 “粘” 在了 LiveData 上等待新的观察者获取。
例如,在一个音乐播放应用中,有一个 LiveData 用于存储当前播放歌曲的信息(如歌名、歌手等)。假设歌曲已经开始播放了一段时间,此时某个 UI 组件(如一个显示歌曲信息的 TextView)才开始订阅这个 LiveData。由于 LiveData 的粘性特性,这个刚订阅的 UI 组件会立刻获取到当前正在播放歌曲的信息,从而能够准确地显示出来,而不需要额外的机制去重新获取这个初始状态的数据。
这种粘性事件特性在很多场景下都很有用。它使得新订阅的观察者能够快速获取到相关数据的当前状态,保证了数据展示的及时性和准确性。不过,在某些特定场景下,如果不希望观察者立即接收到之前已有的值,可能需要对 LiveData 进行一些额外的处理,比如通过设置一个标志位或者使用一些扩展库来实现非粘性的行为。
jetpack 还用过哪些。
除了 LiveData,Android Jetpack 还有很多其他常用的组件。
ViewModel 也是经常使用的。它用于在配置变化(如屏幕旋转)时保存和恢复数据,使得 UI 组件(Activity 或 Fragment)能够在重新创建后快速获取到之前的数据状态。例如,在一个表单填写的 Activity 中,用户输入了部分信息后旋转屏幕,通过 ViewModel 可以确保这些已输入的信息不会丢失,Activity 重新创建后依然可以从 ViewModel 中获取并显示这些数据。
Room 是用于数据库访问的组件。它提供了一种方便的方式来操作 SQLite 数据库,通过定义实体、数据访问对象(DAO)和数据库类等,能够以更简洁、类型安全的方式进行数据库的增删改查操作。比如在一个记录用户消费信息的应用中,可以使用 Room 来管理用户的消费记录数据库,轻松地实现插入新消费记录、查询历史消费等功能。
Navigation 组件用于处理应用内的导航逻辑。它可以定义不同页面(Activity 或 Fragment)之间的导航路径,设置转场动画等。在一个多页面的电商应用中,可以通过 Navigation 组件清晰地规划从商品列表页到商品详情页、再到购物车页以及结算页等的导航流程,并且可以方便地实现页面之间的参数传递。
DataBinding 同样是很实用的组件。它允许在布局文件中直接绑定 ViewModel 中的数据到 UI 视图上,实现数据变化时 UI 自动更新。比如在一个显示用户资料的界面中,通过 DataBinding 可以将 ViewModel 中存储的用户姓名、年龄等数据直接绑定到对应的 TextView 等视图上,当数据发生变化时,视图会自动更新显示内容。
navigation 用来做什么。
Navigation 组件在 Android 应用开发中主要用于处理应用内的导航逻辑。
它可以清晰地定义应用内不同页面(Activity 或 Fragment)之间的导航路径。通过在导航图(Navigation Graph)中设置各个页面之间的连接关系,包括从哪个页面可以导航到另一个页面,以及使用什么样的转场动画等。例如,在一个社交应用中,可以定义从主页面到好友列表页、再到好友详细资料页的导航路径,并且可以指定不同页面切换时是使用淡入淡出动画还是滑动动画等。
Navigation 组件还便于进行参数传递。当从一个页面导航到另一个页面时,可以很方便地将前一个页面的相关数据作为参数传递给下一个页面。比如在一个新闻阅读应用中,从新闻列表页导航到新闻详情页时,可以将所选新闻的 ID 等信息传递给新闻详情页,以便新闻详情页根据这个 ID 来获取并展示详细的新闻内容。
另外,它能够统一管理导航状态。无论是通过返回键、导航栏按钮还是其他方式进行导航操作,Navigation 组件都可以准确地记录和处理这些导航行为,确保应用的导航流程顺畅且符合用户预期。例如,当用户在多个页面之间频繁切换后,通过按返回键能够按照正确的顺序依次返回之前浏览过的页面,而不会出现导航混乱的情况。
而且,Navigation 组件提供了一种可视化的方式来设计导航逻辑。开发人员可以通过 Android Studio 的可视化工具来创建和编辑导航图,直观地看到各个页面之间的关系和导航路径,降低了导航逻辑设计的难度,提高了开发效率。
讲讲进程和线程。
进程是计算机中正在运行的程序的实例,它是操作系统进行资源分配和调度的基本单位。
每个进程都有自己独立的内存空间、代码段、数据段等资源。例如,当你打开一个浏览器应用,操作系统就会为这个浏览器创建一个进程。这个进程拥有自己独立的内存区域,用来存储浏览器程序的代码、用户浏览网页时产生的数据(如书签、浏览历史等)。不同的进程之间相互独立,一个进程的崩溃通常不会直接影响到其他进程的运行。
进程具有以下特点:
- 独立性:进程之间的资源是相互独立的,互不干扰。
- 动态性:进程是随着程序的启动而产生,随着程序的结束而消亡。
- 并发性:多个进程可以在操作系统的调度下同时运行,不过在单核处理器上,实际上是通过时间片轮转等方式实现并发运行的感觉。
线程是进程中的一个执行单元,是比进程更小的能独立运行的基本单位。它是在进程所拥有的资源基础上进行执行的。
以浏览器进程为例,在这个进程里面可能有多个线程在同时运行。比如有一个线程负责加载网页的文本内容,另一个线程负责加载网页中的图片,还有一个线程负责处理用户的点击操作等。
线程的特点如下:
- 轻量级:相对于进程来说,线程创建和销毁的成本较低,因为它不需要重新分配大量的独立资源,只是在进程已有的资源基础上进行。
- 共享性:线程共享进程的资源,如内存空间、代码段等。这使得线程之间可以方便地进行数据交流和协作,但也可能因为共享资源而导致一些并发问题,如数据不一致等。
- 并发性:同进程一样,多个线程也可以在操作系统的调度下同时运行,并且在多核处理器上,多个线程可以真正地同时在不同的核心上运行,提高执行效率。
总体而言,进程侧重于资源分配和管理,而线程侧重于执行具体的任务,通过合理地利用进程和线程,可以提高计算机系统的运行效率和资源利用率。
讲讲 OOM 和内存泄漏。
OOM(Out Of Memory)即内存溢出,是指程序在申请内存时,没有足够的内存空间供其使用。这通常是因为程序创建了过多的对象或者占用了过多的内存资源。
在 Android 开发中,例如加载大型图片时,如果没有对图片进行合适的处理,如缩放或者采用合适的缓存策略,可能会导致内存占用过高。当内存不足以满足新的内存请求时,就会发生 OOM。一个典型的场景是在一个循环中不断地创建新的对象,并且这些对象没有及时被回收,随着对象数量的增加,最终会耗尽内存。
内存泄漏是指程序中已经不再使用的对象,但是因为某些原因,这些对象所占用的内存没有被及时释放。这会导致内存的浪费,严重时也可能导致 OOM。
在 Java 和 Android 中,内存泄漏的常见原因之一是长生命周期的对象持有短生命周期对象的引用。比如在 Activity 中定义了一个内部类,并且这个内部类的生命周期超过了 Activity 本身,内部类持有 Activity 的引用,当 Activity 应该被销毁时,由于内部类的引用,导致 Activity 无法被垃圾回收,从而发生内存泄漏。
另外,没有正确关闭资源也会导致内存泄漏。例如,没有关闭数据库连接、文件流等资源,这些资源所占用的内存就无法被释放。
为了避免 OOM,要合理地管理内存。对于图片等占用大量内存的资源,要进行优化处理,如使用合适的采样率加载图片。对于内存泄漏,要注意对象的生命周期和引用关系,及时释放资源,像在 Activity 销毁时,解除各种监听器和引用关系,确保对象能够被正确地回收。
讲讲单例模式(为什么使用 DLC 不使用懒汉)。
单例模式是一种设计模式,它保证一个类只有一个实例,并提供一个全局访问点来获取这个实例。
双检锁(DCL,Double - Checked Locking)是实现单例模式的一种方式。在多线程环境下,DCL 可以有效地减少锁的开销并且保证单例的唯一性。
DCL 的基本实现思路是在获取单例实例时,先进行一次非锁定检查,看实例是否已经被创建。如果没有被创建,再进入同步块进行第二次检查和实例创建。这样可以避免每次获取实例都进行加锁操作,提高了性能。
而懒汉式单例模式在多线程环境下可能会出现问题。懒汉式是在第一次调用获取实例方法时才创建实例。如果没有适当的同步机制,可能会导致多个线程同时进入实例创建的代码块,从而创建多个实例,违背了单例模式的初衷。
例如,假设有两个线程同时访问懒汉式单例的获取实例方法,当两个线程都判断实例尚未创建时,就会同时执行实例创建的操作,这样就会产生两个不同的实例。
相比之下,DCL 在保证单例的同时,能够利用双检查机制,在多线程环境下高效地提供单例实例。它在第一次检查时可以快速返回已经创建好的实例,只有在实例尚未创建时才会进入同步块进行创建和第二次检查,减少了不必要的锁竞争和等待时间。
装满 7 升水的水杯和装满 13 升水的水杯怎么得到 5 升水,水可以无限加(采用递归实现,不过我讲错了)。
我们先整体看一下这个问题,目标是通过 7 升和 13 升的水杯得到 5 升水。
可以通过以下步骤来实现(非递归方式先解释便于理解):
- 把 13 升的水杯装满,然后用 13 升水杯里的水倒满 7 升的水杯,此时 13 升水杯中剩下 6 升水。
- 把 7 升水杯中的水倒掉,再将 13 升水杯中剩下的 6 升水倒入 7 升水杯,此时 7 升水杯中有 6 升水,13 升水杯为空。
- 把 13 升水杯再次装满,然后用 13 升水杯里的水倒满 7 升水杯(此时 7 升水杯还差 1 升满),这样 13 升水杯中就剩下 12 升水。
- 把 7 升水杯中的水倒掉,再将 13 升水杯中的 12 升水倒满 7 升水杯,这时 13 升水杯中就剩下 5 升水。
如果要采用递归实现这个过程,思路可以是定义一个递归函数,函数的参数可以是两个水杯的当前水量状态。每次递归操作都模拟倒水的过程,并且判断是否达到目标 5 升水的状态。如果没有达到,继续进行下一轮的倒水操作并递归调用这个函数。不过这种递归实现相对复杂,因为需要考虑多种倒水的情况和状态回溯,以避免无限循环。
例如,在递归函数中,可以先判断 13 升水杯满且 7 升水杯不满的情况,进行倒水操作后,更新水杯状态并继续递归。同时,也要考虑其他可能的起始状态和倒水组合,这需要仔细地设计递归的终止条件和状态更新逻辑。