微信 面试
Android 应用的启动流程如下:
当用户点击应用图标时,首先会通过 Launcher(桌面启动器)来响应这个操作。Launcher 本身也是一个 Android 应用,它运行在系统中,负责管理和显示桌面上的图标等信息。
系统会检查应用是否已经有进程存在。如果没有,就会通过 Zygote 进程来孵化一个新的进程。Zygote 是一个特殊的进程,它在系统启动时就已经被创建,其主要作用是通过复制自身来快速创建应用进程,并且在复制过程中会共享一些已经加载好的类和资源,这样可以加快应用的启动速度。
新的应用进程创建后,会加载 ActivityThread 类,这个类是应用的主线程,主要负责管理和分发各种消息,如 Activity 的生命周期方法的调用等。接着会通过 Instrumentation 来创建 Application 对象,这个对象代表了整个应用,在其创建过程中会调用 Application 的 onCreate 方法,在这里可以进行一些全局的初始化操作,比如初始化第三方库等。
然后开始创建启动的 Activity,首先会通过 ActivityManagerService(AMS)进行一系列的验证和准备工作。AMS 会检查 Activity 的配置信息,确保其能够正确启动。之后会调用 Activity 的 onCreate、onStart 和 onResume 等生命周期方法,让 Activity 从创建状态逐步过渡到可见并能够响应用户交互的状态。在 onCreate 方法中,会加载布局文件,进行视图的初始化等操作,onStart 会让 Activity 在屏幕上变得可见,onResume 则使 Activity 能够接收用户的输入等操作。整个过程涉及到系统多个组件之间的交互和协作,从而使得应用能够顺利启动。
Activity A 启动 Activity B 时,生命周期及执行顺序如何?
- 总体执行顺序概述
- 当 Activity A 启动 Activity B 时,Activity A 会经历部分生命周期方法的回调,Activity B 会依次执行完整的生命周期方法来完成创建和显示。
- Activity A 的生命周期变化
- onPause():
- 当启动 Activity B 时,Activity A 首先会执行
onPause()方法。这是因为系统会暂停 Activity A,以便为新的 Activity B 的创建和启动腾出资源。在这个方法中,Activity A 的 UI 仍然可见,但已经失去了焦点,不能再和用户进行交互。例如,如果 Activity A 是一个视频播放应用,在执行onPause()时,视频可能会暂停播放(具体是否暂停还取决于应用的设计)。
- 当启动 Activity B 时,Activity A 首先会执行
- onSaveInstanceState(Bundle outState)(可能执行):
- 这个方法在 Activity A 有可能被系统销毁(例如系统内存不足)的情况下执行。系统会将 Activity A 的一些状态信息存储在
Bundle对象outState中,以便在 Activity A 重新创建时能够恢复这些状态。比如,Activity A 中有一个文本输入框,用户已经输入了一些文字,通过onSaveInstanceState()可以将这些文字保存起来。但如果系统确定 Activity A 不会被销毁,这个方法就不会执行。
- 这个方法在 Activity A 有可能被系统销毁(例如系统内存不足)的情况下执行。系统会将 Activity A 的一些状态信息存储在
- onPause():
- Activity B 的生命周期执行
- onCreate(Bundle savedInstanceState):
- 这是 Activity B 生命周期的第一个方法。在这里可以进行一些初始化操作,比如设置布局、初始化成员变量等。如果 Activity B 是从之前被销毁的状态恢复过来的,
savedInstanceState参数会包含之前保存的状态信息,可用于恢复 Activity B 的状态。例如,通过获取savedInstanceState中的数据来恢复之前列表的滚动位置。
- 这是 Activity B 生命周期的第一个方法。在这里可以进行一些初始化操作,比如设置布局、初始化成员变量等。如果 Activity B 是从之前被销毁的状态恢复过来的,
- onStart():
- 在
onCreate()之后执行。此时 Activity B 已经完成了初始化,并且即将对用户可见。在这个阶段,Activity B 还没有获取焦点,用户还不能与之交互。比如,Activity B 中的动画可以在这个阶段开始准备加载。
- 在
- onResume():
- 接着
onStart()执行。onResume()方法执行后,Activity B 获取焦点,完全显示在前台,用户可以正常地和它进行交互,如点击按钮、输入文字等操作。例如,一个游戏 Activity 在onResume()执行后,游戏的控制逻辑开始接收用户输入并更新游戏画面。
- 接着
- onCreate(Bundle savedInstanceState):
- 特殊情况说明
- 如果 Activity B 使用了透明主题,那么 Activity A 在 Activity B 启动后可能不会执行
onStop()方法,因为 Activity A 仍然部分可见。 - 如果系统内存紧张,Activity A 在执行
onPause()后可能会很快被销毁,其onStop()和onDestroy()方法也会被执行,以释放内存资源。
- 如果 Activity B 使用了透明主题,那么 Activity A 在 Activity B 启动后可能不会执行
如何处理一张较大的Bitmap?
处理较大的 Bitmap(位图)时可以考虑以下几种方法:
一、内存管理方面
- 采样缩放(Sampling)
- 原理:
- 在将 Bitmap 加载到内存之前,通过设置合适的采样率来减小其尺寸。例如,在 Android 中,可以使用
BitmapFactory.Options类来实现。通过设置inSampleSize参数,它是一个整数,表示采样率。如果inSampleSize的值为 2,那么生成的 Bitmap 的宽和高都会变为原来的 1/2,像素数量变为原来的 1/4,从而大大减少内存占用。
- 在将 Bitmap 加载到内存之前,通过设置合适的采样率来减小其尺寸。例如,在 Android 中,可以使用
- 示例代码(Android):
- 原理:
public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
int reqWidth, int reqHeight) {
// 首先设置inJustDecodeBounds = true来获取原始图像的尺寸信息
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId, options);
// 计算inSampleSize的值
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// 然后设置inJustDecodeBounds = false,真正加载缩放后的图像
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res, resId, options);
}
private static int calculateInSampleSize(BitmapFactory.Options options,
int reqWidth, int reqHeight) {
// 原始图像的宽和高
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
// 不断地将inSampleSize乘以2,直到满足要求
while ((halfHeight / inSampleSize) >= reqHeight
&& (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
- 应用场景:
- 当需要在有限内存的设备上显示缩略图或者不需要原始高分辨率的场景,如在列表视图中显示图片列表。
- 使用合适的图像格式和色彩模式
- 原理:
- 不同的图像格式和色彩模式具有不同的内存占用情况。例如,在某些情况下,将 RGB_8888(每个像素占用 4 字节)格式的 Bitmap 转换为 RGB_565(每个像素占用 2 字节)格式可以减少内存占用。RGB_8888 可以表示更丰富的颜色,但对于一些对颜色精度要求不高的场景,RGB_565 就足够了,并且能节省一半的内存。
- 示例代码(Android):
- 原理:
Bitmap originalBitmap = // 假设已经加载的原始Bitmap
Bitmap.Config config = Bitmap.Config.RGB_565;
Bitmap convertedBitmap = Bitmap.createBitmap(originalBitmap.getWidth(),
originalBitmap.getHeight(), config);
Canvas canvas = new Canvas(convertedBitmap);
Paint paint = new Paint();
canvas.drawBitmap(originalBitmap, 0, 0, paint);
- 应用场景:
- 适用于对颜色显示质量要求不是极高的应用场景,如简单的图标显示、地图标记等。
- 及时回收内存(Recycle)
- 原理:
- 当 Bitmap 不再使用时,手动调用
recycle方法可以释放其占用的内存。但是需要注意的是,一旦 Bitmap 被回收,就不能再使用它了,否则会导致程序崩溃。并且在 Android 系统中,垃圾回收机制也会自动回收没有引用的 Bitmap 对象,但是手动回收可以更及时地释放内存。
- 当 Bitmap 不再使用时,手动调用
- 示例代码(Android):
- 原理:
Bitmap bitmap = // 假设已经加载的Bitmap
// 使用完Bitmap后
bitmap.recycle();
bitmap = null;
System.gc();// 可以尝试触发垃圾回收,但不能保证立即回收
- 应用场景:
- 在 Activity 或 Fragment 销毁时,或者加载新的一批 Bitmap 替换旧的 Bitmap 时,及时回收不再使用的 Bitmap。
二、性能优化方面
- 缓存策略(Caching)
- 原理:
- 使用缓存来存储已经处理过或者经常使用的 Bitmap。例如,在内存缓存(如
LruCache)和磁盘缓存(如DiskLruCache)中存储 Bitmap。LruCache(Least Recently Used Cache)基于最近最少使用原则,当缓存达到设定的容量上限时,会自动删除最久未使用的元素。磁盘缓存则可以将 Bitmap 存储在本地文件系统中,以便下次使用时可以快速加载,而不需要重新从网络或其他资源获取。
- 使用缓存来存储已经处理过或者经常使用的 Bitmap。例如,在内存缓存(如
- 示例代码(Android):
- 以下是使用
LruCache作为内存缓存的简单示例:
- 以下是使用
- 原理:
private LruCache<String, Bitmap> mMemoryCache;
@Override
protected void onCreate(Bundle savedInstanceState) {
// 计算可用于缓存的最大内存(单位:字节)
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
// 使用1/8的可用内存作为缓存大小
final int cacheSize = maxMemory / 8;
mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
// 计算每个Bitmap对象的大小(单位:KB)
return bitmap.getByteCount() / 1024;
}
};
}
public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
if (getBitmapFromMemoryCache(key) == null) {
mMemoryCache.put(key, bitmap);
}
}
public Bitmap getBitmapFromMemoryCache(String key) {
return mMemoryCache.get(key);
}
- 应用场景:
- 适用于频繁加载相同或相似 Bitmap 的应用,如图片浏览器应用,当用户来回浏览图片时,缓存可以快速提供图片,减少加载时间。
- 异步加载(Asynchronous Loading)
- 原理:
- 在后台线程中加载 Bitmap,避免阻塞主线程。例如,在 Android 中可以使用
AsyncTask或者HandlerThread等方式。AsyncTask可以方便地在后台执行任务(如加载 Bitmap),并在任务完成后更新 UI。HandlerThread则提供了一种简单的线程间通信机制,用于在后台线程处理任务,通过Handler来发送和接收消息。
- 在后台线程中加载 Bitmap,避免阻塞主线程。例如,在 Android 中可以使用
- 示例代码(Android):
- 以下是使用
AsyncTask加载 Bitmap 的示例:
- 以下是使用
- 原理:
class LoadBitmapTask extends AsyncTask<Integer, Void, Bitmap> {
private ImageView mImageView;
public LoadBitmapTask(ImageView imageView) {
mImageView = imageView;
}
@Override
protected Bitmap doInBackground(Integer... params) {
// 假设这里是从资源ID加载Bitmap,实际可以是从网络等其他方式
int resId = params[0];
return BitmapFactory.decodeResource(getResources(), resId);
}
@Override
protected void onPostExecute(Bitmap bitmap) {
mImageView.setImageBitmap(bitmap);
}
}
// 使用方式
ImageView imageView = findViewById(R.id.image_view);
new LoadBitmapTask(imageView).execute(R.drawable.large_image);
- 应用场景:
- 当需要加载较大的 Bitmap 并且在 UI 界面显示时,如在应用启动时加载背景图片或者在图片详情页面加载高清大图。
- 区域加载和显示(Region Loading and Display)
- 原理:
- 对于非常大的 Bitmap,不需要一次性全部显示或者处理。可以只加载和显示用户当前关注的区域。例如,在地图应用中,只加载和渲染用户当前视野范围内的地图部分,而不是整个地图 Bitmap。在 Android 中,可以使用
BitmapRegionDecoder来实现区域解码。
- 对于非常大的 Bitmap,不需要一次性全部显示或者处理。可以只加载和显示用户当前关注的区域。例如,在地图应用中,只加载和渲染用户当前视野范围内的地图部分,而不是整个地图 Bitmap。在 Android 中,可以使用
- 示例代码(Android):
- 原理:
try {
BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(inputStream, false);
// 设置要解码的区域,这里假设从(0,0)开始,宽和高分别为100像素
Rect rect = new Rect(0, 0, 100, 100);
Bitmap bitmap = decoder.decodeRegion(rect, null);
// 可以将解码后的区域显示在ImageView等控件上
imageView.setImageBitmap(bitmap);
} catch (IOException e) {
e.printStackTrace();
}
- 应用场景:
- 适用于处理超大尺寸的图像,如高分辨率的卫星地图、大幅面的扫描文档等,只显示和处理用户当前查看的部分,节省内存和提高性能。
如何压缩Bitmap?
在 Android 开发中,压缩Bitmap可以通过多种方式来实现,以下是几种常见的方法:
质量压缩(Quality Compression)
质量压缩主要是通过改变图像的存储质量来减小文件大小,它不会改变图像的尺寸(像素数)。这种方法适用于对图像质量要求不是特别高的场景,比如在网络传输或者存储一些缩略图时。
- 使用 Bitmap.compress 方法(以 JPEG 格式为例)
- 示例代码如下:
public void compressBitmap(Bitmap bitmap) {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
// 第一个参数是图像格式,这里是JPEG
// 第二个参数是压缩质量,范围是0 - 100,数值越小质量越低,文件越小
// 第三个参数是输出流
bitmap.compress(Bitmap.CompressFormat.JPEG, 50, outputStream);
try {
byte[] compressedData = outputStream.toByteArray();
// 这里可以将compressedData用于存储或者网络传输等操作
} catch (Exception e) {
e.printStackTrace();
}
}
- 在上述代码中,
Bitmap.CompressFormat还可以是PNG等格式。不过需要注意的是,PNG是无损压缩格式,质量参数对它的文件大小影响不像对JPEG那么明显。因为PNG主要通过对图像数据的无损编码来减小文件大小,而JPEG可以通过降低图像质量(损失一定的图像细节)来大幅减小文件大小。
尺寸压缩(Scaling Compression)
尺寸压缩是通过改变图像的尺寸来减小文件大小。这是一种比较直接的方式,适用于需要将大尺寸图像缩小显示或者存储的场景。
- 使用 Matrix 进行缩放
- 示例代码如下:
public Bitmap scaleBitmap(Bitmap bitmap, float scaleFactor) {
int width = bitmap.getWidth();
int height = bitmap.getHeight();
Matrix matrix = new Matrix();
matrix.postScale(scaleFactor, scaleFactor);
return Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, false);
}
- 在这个代码中,
scaleFactor是缩放因子。例如,如果scaleFactor为 0.5,那么图像的宽度和高度都会变为原来的一半。需要注意的是,这种缩放方式可能会导致图像变得模糊,特别是在放大图像时,因为它是简单的基于像素的缩放。
- 使用 Bitmap.createScaledBitmap 方法
- 示例代码如下:
public Bitmap createScaledBitmap(Bitmap bitmap, int newWidth, int newHeight) {
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true);
}
- 这里的
newWidth和newHeight是你想要得到的缩放后的图像宽度和高度。这个方法内部也是通过类似Matrix的方式来进行缩放操作的。
在实际应用中,也可以结合质量压缩和尺寸压缩来达到更好的压缩效果。例如,先对图像进行尺寸缩小,然后再进行质量压缩,这样可以在保证图像基本满足需求的情况下,最大限度地减小文件大小。同时,在处理Bitmap时,还需要注意内存管理,因为Bitmap对象可能会占用大量的内存,特别是在处理高分辨率图像时。
Activity的启动模式有哪些?它们对生命周期回调有何影响?
- Activity 的启动模式
- standard(标准模式)
- 这是默认的启动模式。在这种模式下,每当启动一个新的 Activity 实例时,系统都会创建一个新的 Activity 实例并将其放入任务栈中。例如,假设当前有一个 Activity A,当从 A 启动另一个 Activity B(B 的启动模式为 standard)时,系统会创建一个新的 B 实例并将其放入任务栈顶。如果再次从 B 启动 B,就会又创建一个新的 B 实例放入栈顶,就像堆积木一样,每次都在栈顶添加新的元素。
- singleTop(栈顶复用模式)
- 当启动的 Activity 已经位于任务栈的栈顶时,系统不会创建新的实例,而是直接复用栈顶的 Activity 实例,并调用它的
onNewIntent()方法。例如,有一个 Activity C,它的启动模式是 singleTop。如果 C 已经在栈顶,当再次启动 C 时,不会创建新的 C 实例,而是复用已有的栈顶 C 实例,并且通过onNewIntent()方法传递新的启动意图。但是,如果 C 不在栈顶,就会像 standard 模式一样创建一个新的 C 实例并放入栈顶。
- 当启动的 Activity 已经位于任务栈的栈顶时,系统不会创建新的实例,而是直接复用栈顶的 Activity 实例,并调用它的
- singleTask(栈内复用模式)
- 这种模式下,系统会检查任务栈中是否已经存在该 Activity 的实例。如果存在,系统会将该实例之上的所有 Activity 实例全部出栈,使这个 Activity 实例位于栈顶,然后调用它的
onNewIntent()方法。例如,有一个 Activity D,启动模式为 singleTask。如果任务栈中有 D 的实例,并且栈中有其他 Activity 位于 D 之上,当启动 D 时,这些位于 D 之上的 Activity 会被弹出栈,D 成为栈顶,并且通过onNewIntent()接收新意图。如果任务栈中不存在 D 的实例,就会创建一个新的 D 实例并放入栈中。
- 这种模式下,系统会检查任务栈中是否已经存在该 Activity 的实例。如果存在,系统会将该实例之上的所有 Activity 实例全部出栈,使这个 Activity 实例位于栈顶,然后调用它的
- singleInstance(单实例模式)
- 此模式会为该 Activity 单独开辟一个新的任务栈来存放它。这个 Activity 在整个系统中只有一个实例,并且这个实例所在的任务栈中只有它自己。当启动这个 Activity 时,如果它不存在,就会创建一个新的任务栈和一个新的 Activity 实例放入其中。其他 Activity 无法与它存在于同一个任务栈中。比如,Activity E 是 singleInstance 模式,启动 E 后,它会在一个单独的任务栈中,当从其他 Activity 启动 E 时,会切换到 E 所在的单独任务栈。
- standard(标准模式)
- 对生命周期回调的影响
- standard 模式
- 每次创建新的 Activity 实例,它的完整生命周期方法都会被依次调用,即
onCreate()、onStart()、onResume()等。当 Activity 被销毁时,会依次调用onPause()、onStop()、onDestroy()。例如,每次启动一个新的 standard 模式的 Activity,onCreate()方法都会被调用,在这里可以进行视图的初始化等操作。
- 每次创建新的 Activity 实例,它的完整生命周期方法都会被依次调用,即
- singleTop 模式
- 当复用栈顶 Activity 时,只有
onNewIntent()方法会被调用,而onCreate()等其他生命周期方法不会被再次调用。这是因为 Activity 实例已经存在并且处于合适的状态(在栈顶)。但是如果不是复用情况,即新创建 Activity 实例,那么它的完整生命周期方法会和 standard 模式一样依次被调用。
- 当复用栈顶 Activity 时,只有
- singleTask 模式
- 当复用栈内已有的 Activity 实例时,会先把它上面的 Activity 出栈,此时上面的 Activity 会依次调用
onPause()、onStop()、onDestroy()。被复用的 Activity 会调用onNewIntent(),然后再依次调用onRestart()、onStart()、onResume(),重新回到前台并更新状态。如果是新创建 Activity 实例(任务栈中不存在该 Activity),其生命周期方法的调用和 standard 模式一样。
- 当复用栈内已有的 Activity 实例时,会先把它上面的 Activity 出栈,此时上面的 Activity 会依次调用
- singleInstance 模式
- 当创建新的 Activity 实例时,其生命周期方法调用和 standard 模式类似。不过由于它在单独的任务栈中,当它所在的任务栈切换到前台或者后台时,也会相应地调用
onResume()、onPause()等方法。并且因为它单独存在,其他 Activity 启动它或者它启动其他 Activity 时,任务栈的切换情况比较特殊,会涉及到不同任务栈之间的切换管理,这也会影响到生命周期方法的调用时机。例如,当从一个任务栈中的 Activity 启动 singleInstance 模式的 Activity 时,启动的 Activity 所在任务栈会被暂停,singleInstance 模式的 Activity 所在任务栈会被激活并将其启动,相应的生命周期方法会被调用。
- 当创建新的 Activity 实例时,其生命周期方法调用和 standard 模式类似。不过由于它在单独的任务栈中,当它所在的任务栈切换到前台或者后台时,也会相应地调用
- standard 模式
Android JVM 的组成部分有哪些?
在 Android 中,并不是传统意义上的 JVM(Java 虚拟机),而是使用的 Dalvik 虚拟机(早期)和 ART 虚拟机(现在主流)。
Dalvik 虚拟机主要由字节码解释器、即时编译器(JIT)、垃圾回收器(GC)、类加载器等部分组成。字节码解释器用于执行.dex 文件中的字节码指令。.dex 文件是 Android 对.class 文件进行优化后的格式,这种格式更适合移动设备的存储和执行。即时编译器(JIT)会在程序运行过程中,对一些频繁执行的代码片段进行编译优化,将字节码转换为机器码,从而提高执行效率。垃圾回收器(GC)负责回收不再使用的对象内存,它采用了标记 - 清除等算法来管理内存。类加载器用于加载.dex 文件中的类定义,当程序需要使用某个类时,类加载器会将其加载到内存中。
ART 虚拟机在继承了 Dalvik 部分功能的基础上有了进一步的优化。它主要包含了 AOT(Ahead - Of - Time)编译器、优化的垃圾回收器和类加载器等。AOT 编译器会在应用安装时,将字节码预先编译成机器码,这样应用在启动和运行过程中的执行速度会更快,减少了即时编译的开销。其垃圾回收器在内存管理上更加高效,能够更好地处理内存碎片等问题。在类加载方面,ART 虚拟机也有改进,能够更快速地加载和初始化类,提高应用的启动性能。
无论是 Dalvik 还是 ART,它们都依赖于 Android 系统的底层库,这些底层库提供了各种功能,如文件操作、网络通信、图形绘制等接口,虚拟机通过调用这些底层库来实现完整的应用功能。同时,它们还和 Android 的运行时环境紧密结合,包括对进程管理、线程调度等方面的支持,确保应用在 Android 系统中能够稳定、高效地运行。
Android 布局优化有哪些方法?
首先,可以使用布局复用。比如可以使用<include>标签来复用布局文件。假设我们有一个通用的标题栏布局,这个标题栏在多个 Activity 中都要使用。我们可以将标题栏的布局单独写在一个 XML 文件中,然后在需要使用这个标题栏的 Activity 布局文件中,通过<include>标签将其引入。这样不仅减少了代码的重复编写,还能在一定程度上提高布局加载的效率。
其次,减少布局的嵌套层级。过多的嵌套会导致布局的性能下降。可以使用相对布局(RelativeLayout)或者约束布局(ConstraintLayout)来减少嵌套。相对布局可以通过相对位置的方式来排列视图,例如让一个按钮在另一个按钮的下方或者右方等。约束布局则更加灵活,它可以通过添加约束条件来确定视图的位置,比如让一个视图与父布局的边缘保持一定的距离,或者与其他视图保持某种对齐关系。
另外,对于一些不需要一开始就显示的视图,可以使用 ViewStub。ViewStub 是一个轻量级的视图,它在初始状态下不会占用太多资源。当需要显示这个视图时,通过调用它的 inflate 方法,就可以加载对应的布局并显示出来。例如,一个包含详细信息的视图,只有当用户点击查看详细信息按钮时才需要显示,那么这个详细信息的布局就可以放在 ViewStub 中。
还有,要合理设置视图的宽高属性。尽量避免使用 WRAP_CONTENT 和 MATCH_PARENT 同时出现在嵌套的布局中,因为这样可能会导致多次测量(measure)过程,增加性能开销。如果能够确定视图的大小,最好直接设置具体的尺寸数值,或者根据屏幕密度等因素动态计算合适的尺寸。
在加载布局资源时,可以使用异步加载的方式。对于一些比较复杂或者资源占用大的布局,可以在后台线程中进行加载,当加载完成后再更新到 UI 线程中显示。这样可以避免在布局加载过程中,UI 线程长时间被阻塞,提高应用的响应速度。
RecyclerView 和 ListView 的主要区别是什么?
从视图复用角度来看,ListView 有自己的复用机制,它通过 BaseAdapter 中的 getView 方法来实现视图的复用。在这个方法中,通过 convertView 参数来判断是否有可以复用的视图。如果 convertView 为 null,就创建一个新的视图;如果不为 null,就可以对这个复用的视图进行数据更新等操作。RecyclerView 则是通过 RecyclerView.ViewHolder 来实现更高效的视图复用。ViewHolder 模式将视图的引用保存在 ViewHolder 对象中,当需要复用视图时,直接从 ViewHolder 中获取视图引用,并且 RecyclerView 可以方便地实现多种布局类型的复用,例如线性布局、网格布局、瀑布流布局等。
在布局灵活性方面,ListView 主要是基于垂直方向的列表布局,虽然通过一些自定义的方式也可以实现其他布局方式,但相对比较复杂。而 RecyclerView 在布局方面具有很强的灵活性,通过设置不同的 LayoutManager 就可以实现不同的布局效果。例如,使用 LinearLayoutManager 可以实现线性布局(垂直或水平),使用 GridLayoutManager 可以实现网格布局,使用 StaggeredGridLayoutManager 可以实现瀑布流布局。
从数据更新方面,ListView 在数据更新时,通常需要调用 Adapter 的 notifyDataSetChanged 方法来刷新整个列表。这种方式在数据量较大或者只需要更新部分数据时,可能会导致性能问题,因为它会重新加载所有的视图。RecyclerView 提供了更细粒度的数据更新方法,比如 notifyItemInserted、notifyItemRemoved、notifyItemChanged 等。这些方法可以精确地更新某一个或几个特定的项目,而不需要重新加载整个列表,提高了数据更新的效率。
在性能方面,由于 RecyclerView 的视图复用机制和更细粒度的数据更新方式,在处理大量数据和复杂布局时,通常比 ListView 具有更好的性能。特别是在滚动列表时,RecyclerView 能够更平滑地加载和显示视图,减少卡顿现象。
在扩展性方面,RecyclerView 具有更好的扩展性。它可以方便地添加各种装饰器(ItemDecoration)来实现添加分割线、分组等功能。同时,它的动画效果实现也比较方便,通过默认的动画支持或者自定义动画,可以让列表项的添加、删除等操作具有更好的视觉效果。
View 绘制流程是怎样的?
View 的绘制流程主要分为三个阶段:测量(measure)、布局(layout)和绘制(draw)。
在测量阶段,父视图会遍历它的所有子视图,调用子视图的 measure 方法。这个方法用于确定子视图的大小。子视图会根据自身的布局参数(例如宽度是 MATCH_PARENT、WRAP_CONTENT 还是具体的数值等)和父视图提供的约束条件来计算自己的大小。对于 ViewGroup 类型的视图,它还需要考虑子视图的数量和排列方式等因素。在这个过程中,会传递两个参数,一个是 MeasureSpec,它包含了视图的大小和模式信息,模式主要有三种:UNSPECIFIED(没有限制)、EXACTLY(精确大小)和 AT_MOST(最多是某个大小)。通过对这些参数的处理,子视图会计算出自己的测量宽高,然后将结果存储起来。
布局阶段,在测量完成后,父视图会调用子视图的 layout 方法。这个方法用于确定子视图在父视图中的位置。子视图会根据自己的测量宽高和父视图分配的位置信息(例如偏移量等)来确定自己的最终位置。同样,对于 ViewGroup 类型的视图,它需要在这个阶段正确地摆放所有的子视图,根据布局方式(如线性布局是按照水平或垂直方向依次排列,相对布局是根据相对位置关系排列等)来设置子视图的位置。
绘制阶段是最后一个阶段,在这个阶段,视图会调用自己的 draw 方法来将自己绘制到屏幕上。这个过程包括绘制背景、绘制自己(例如绘制一个 TextView 的文字内容、绘制一个 ImageView 的图片等)、绘制子视图(如果是 ViewGroup 类型的视图)和绘制装饰(如滚动条等)。在绘制自己的部分,不同类型的视图会有不同的绘制操作。例如,对于一个自定义的视图,可能会通过重写 onDraw 方法,使用 Canvas 和 Paint 等工具来进行图形绘制。Canvas 提供了各种绘制方法,如绘制直线、矩形、圆形等,Paint 则用于设置绘制的颜色、样式等属性。通过这三个阶段的有序进行,一个完整的视图就被绘制到屏幕上,供用户查看和交互。
Handler 机制是如何工作的?为何 loop 方法不会造成 ANR?
Handler 机制主要用于在 Android 中实现线程间的通信,特别是在主线程和子线程之间传递消息。
Handler 主要包含四个重要的部分:Handler 对象、MessageQueue、Looper 和 Message。Message 是消息的载体,它包含了消息的内容、消息的目标 Handler 等信息。MessageQueue 是一个消息队列,它用于存储 Message 对象,并且按照消息的发送时间等顺序进行排列。Looper 是一个循环器,它会不断地从 MessageQueue 中取出消息,并将消息分发给对应的 Handler。Handler 则是用于发送和处理消息的对象。
当在一个线程中创建一个 Handler 时,这个线程必须已经有一个 Looper 与之关联。通常在主线程中,系统会自动创建一个 Looper,这个 Looper 会在应用启动后一直循环运行,不断地从 MessageQueue 中获取消息。在子线程中,如果想要使用 Handler,需要先调用 Looper.prepare 方法创建一个 Looper,然后通过 Looper.loop 方法启动这个 Looper。
当想要发送一个消息时,通过 Handler 的 sendMessage 或者 post 等方法。这些方法会将一个 Message 对象添加到 MessageQueue 中。然后 Looper 会在循环过程中检测到这个新添加的消息,将其取出,并根据消息中的目标 Handler 信息,将消息分发给对应的 Handler。Handler 收到消息后,会在 handleMessage 方法中对消息进行处理,这个方法可以根据消息的内容来执行相应的操作,比如更新 UI 等。
关于为什么 loop 方法不会造成 ANR(应用无响应),这是因为 Android 系统对于主线程的消息处理是有一定容忍度的。在主线程的 Looper.loop 方法运行过程中,它会不断地检查是否有新的消息到来。如果有新的消息,就处理消息;如果没有消息,它会进入阻塞状态,等待消息到来。这种阻塞状态并不是完全占用 CPU 资源,而是一种等待机制。当系统检测到用户操作(如触摸屏幕、按键等)或者其他重要事件时,会将这些事件包装成消息发送到主线程的 MessageQueue 中,然后 Looper 会及时唤醒并处理这些消息。所以,只要消息能够及时被处理,就不会出现应用无响应的情况。而且,在处理消息的过程中,如果某个消息的处理时间过长,可能会导致后续消息的延迟处理,但这并不一定会导致 ANR,只有当整个主线程长时间被某个操作或者消息处理完全阻塞,无法响应系统的其他重要事件时,才会出现 ANR。
**SingleTop 和 standard 启动模式下,生命周期回调有何不同?
在 standard 模式下,每次启动一个 Activity,都会创建一个新的 Activity 实例并放入任务栈中。这就导致生命周期回调方法会完整地按照常规顺序被调用。比如,当启动一个 Activity 时,会先调用 onCreate 方法,在这里进行视图加载(通过 setContentView)、初始化数据等操作。然后会调用 onStart 方法,此时 Activity 对用户变为可见状态。接着是 onResume 方法,Activity 获取焦点,开始和用户交互。当这个 Activity 被其他 Activity 覆盖时,会调用 onPause 和 onStop 方法;当它再次回到前台时,会调用 onRestart、onStart 和 onResume 方法。
而在 SingleTop 模式下,如果要启动的 Activity 已经处于栈顶,系统不会再创建新的 Activity 实例,而是直接调用这个栈顶 Activity 的 onNewIntent 方法。此时,像 onCreate 这些生命周期回调方法不会被调用。不过,如果要启动的 Activity 不在栈顶,就会像 standard 模式一样创建新的实例,生命周期方法也会正常调用。例如,假设有 Activity A、B、C 按顺序在栈中,A 处于栈底,C 处于栈顶。如果此时启动一个 SingleTop 模式的 C,就不会创建新的 C 实例,而是直接调用 C 的 onNewIntent 方法。但如果启动的是 SingleTop 模式的 B,由于 B 不在栈顶,就会创建新的 B 实例,并且依次调用 onCreate、onStart、onResume 等生命周期方法。这种不同的生命周期回调机制使得 SingleTop 模式适用于一些特定场景,如消息推送等。当消息推送对应的 Activity 已经在栈顶打开,就不需要重新创建,直接处理新的消息意图即可,而 standard 模式则更适合每次都需要全新的 Activity 实例来处理的情况。
onStart 和 onResume 的区别是什么?
onStart 方法和 onResume 方法在 Activity 的生命周期中都与 Activity 的可见性和交互性有关,但它们有着明显的区别。
当 Activity 调用 onStart 方法时,它表示这个 Activity 对用户变为可见状态。不过,此时 Activity 可能还没有获取焦点,例如,当一个透明的或者半透明的 Activity 覆盖在当前 Activity 之上时,被覆盖的 Activity 会调用 onPause 方法,而当这个透明的 Activity 消失后,被覆盖的 Activity 会调用 onStart 方法,重新变为可见状态,但还没有获取焦点来和用户进行交互。onStart 方法主要是 Activity 可见性变化的一个标志,它是在 Activity 从不可见(如处于停止状态或者尚未创建)到可见的过程中的一个重要回调。
而 onResume 方法是在 onStart 方法之后被调用的,它表示 Activity 获取了焦点,可以开始和用户进行交互。例如,当用户可以在 Activity 中进行点击按钮、输入文字等操作时,就说明这个 Activity 处于 onResume 状态。在 onResume 方法中,通常可以进行一些和用户交互相关的初始化操作,比如开启传感器监听(如加速度计、陀螺仪等),因为此时 Activity 已经完全准备好接收用户的操作并且能够及时响应。从系统资源角度来看,处于 onResume 状态的 Activity 会占用更多的系统资源来保证交互的流畅性,如 CPU 时间片等。当 Activity 失去焦点(如被其他 Activity 覆盖或者用户切换到其他应用)时,会先调用 onPause 方法,此时 Activity 暂停交互相关的操作,然后如果 Activity 完全不可见,会进一步调用 onStop 方法。
Service 和 AsyncTask 的对比。
Service 是一种可以在后台长时间运行的组件,它不提供用户界面。主要用于执行一些长时间运行的操作,即使应用的 Activity 组件被销毁,Service 仍然可以继续运行。例如,一个音乐播放服务,当用户退出音乐播放界面后,音乐依然可以在后台播放。Service 有两种启动方式,一种是 startService,通过这种方式启动的 Service 会一直运行,直到调用 stopService 或者 Service 自己完成任务后停止。另一种是 bindService,这种方式主要用于和其他组件(如 Activity)进行绑定,当所有绑定的组件都与 Service 解除绑定后,Service 才会停止。Service 在后台运行时,会占用一定的系统资源,并且运行在主线程中,所以如果 Service 中有比较耗时的操作,需要开启新的线程来执行,否则可能会导致应用无响应(ANR)。
AsyncTask 是一个抽象类,它是对线程和 Handler 机制的一种简单封装,用于在后台执行一些异步操作并且能够方便地在主线程更新 UI。AsyncTask 内部维护了一个后台线程来执行耗时操作,并且通过 Handler 将执行结果传递回主线程。它有几个重要的方法,如 doInBackground 方法,这个方法在后台线程中执行,用于执行具体的耗时任务,比如网络请求、文件读取等。onPostExecute 方法在 doInBackground 方法执行完成后在主线程中被调用,用于更新 UI 等操作。与 Service 不同,AsyncTask 主要用于执行一次性的、相对较短时间的异步任务,并且和 Activity 等组件的生命周期关联比较紧密。如果 Activity 被销毁,AsyncTask 可能也会随之结束(取决于具体的实现)。AsyncTask 在使用上比较方便,适用于一些简单的异步场景,如加载少量数据并更新 UI,而 Service 更适合那些需要长时间在后台运行,不依赖于某个特定 Activity 的任务。
EventBus 和回调的对比,有没有其他解决类间耦合性的办法?
EventBus 是一种事件发布 - 订阅的框架。它允许不同的组件(如 Activity、Fragment 等)之间进行解耦通信。通过 EventBus,一个组件可以发布一个事件,而其他对这个事件感兴趣的组件可以订阅这个事件。当事件被发布时,所有订阅了这个事件的组件都会收到通知并进行相应的处理。例如,在一个多模块的应用中,模块 A 完成了一个数据加载任务,它可以发布一个 “数据加载完成” 的事件,模块 B 和 C 如果订阅了这个事件,就可以收到通知并进行数据展示或者其他相关操作。使用 EventBus 可以大大降低组件之间的直接依赖,使得代码的可维护性和扩展性更好。
回调则是一种比较传统的通信方式。它通常是在一个组件中定义一个接口,另一个组件实现这个接口,然后将实现了接口的对象作为参数传递给第一个组件。当第一个组件中的某个事件发生时,通过调用接口中的方法来通知第二个组件。例如,在一个网络请求库中,库的使用者可以实现一个回调接口,当网络请求完成时,库会通过调用这个回调接口中的方法来通知使用者请求的结果。回调的方式相对比较直观,但是会导致代码之间的耦合性比较强,因为需要在一个组件中明确地引用另一个组件的接口。
除了 EventBus 和回调之外,还有其他解决类间耦合性的办法。比如使用接口隔离原则,将不同的功能定义在不同的接口中,让组件只依赖于它们真正需要的接口,而不是整个类。还可以使用依赖注入框架,如 Dagger2。Dagger2 通过在编译时生成依赖关系图,将依赖的对象注入到需要的组件中,使得组件之间的依赖关系更加清晰和易于管理。另外,使用广播机制也可以在一定程度上解决类间耦合问题,通过发送和接收广播,不同的组件可以在不知道对方具体存在的情况下进行通信,不过广播机制相对比较消耗资源,并且需要注意安全性和效率问题。
Android 事件分发机制是怎样的?
Android 事件分发机制主要涉及三个重要的方法:dispatchTouchEvent、onInterceptTouchEvent 和 onTouchEvent,这三个方法主要存在于 ViewGroup 和 View 中。
对于 ViewGroup 来说,当一个触摸事件发生时,首先会调用它的 dispatchTouchEvent 方法。这个方法的主要作用是对事件进行分发。它会首先判断是否需要拦截这个事件,这是通过调用 onInterceptTouchEvent 方法来实现的。如果 onInterceptTouchEvent 方法返回 true,表示这个 ViewGroup 要拦截这个事件,那么这个事件就不会再传递给它的子视图,而是由这个 ViewGroup 自己来处理,通过调用自己的 onTouchEvent 方法来处理这个事件。如果 onInterceptTouchEvent 方法返回 false,表示这个 ViewGroup 不拦截这个事件,那么这个事件就会传递给它的子视图。
在传递给子视图时,ViewGroup 会遍历它的子视图,根据触摸点的位置等因素来确定哪个子视图应该接收这个事件。通常是通过判断触摸点是否在子视图的范围内来确定。当找到对应的子视图后,就会调用这个子视图的 dispatchTouchEvent 方法,开始在子视图中进行事件分发。这个过程是递归的,也就是说,如果子视图也是 ViewGroup 类型,那么同样会按照上述的流程进行事件分发和拦截。
对于 View 来说,它没有 onInterceptTouchEvent 方法,因为它不能再拦截事件传递给它的子视图(View 本身没有子视图)。当 View 的 dispatchTouchEvent 方法被调用时,它会直接调用自己的 onTouchEvent 方法来处理这个事件。如果 onTouchEvent 方法返回 true,表示这个 View 成功地处理了这个事件,那么这个事件就不会再向上传递;如果 onTouchEvent 方法返回 false,表示这个 View 没有处理这个事件,那么这个事件就会向上传递给它的父视图,由父视图的 onTouchEvent 方法来处理,或者如果父视图是 ViewGroup 类型并且没有拦截这个事件,就会继续向上传递,直到被某个视图成功处理或者传递到顶层的 Activity。在这个过程中,还可以通过设置 OnTouchListener 来在事件传递到 onTouchEvent 方法之前进行处理,这也增加了事件处理的灵活性。
Android 进程通信机制有哪些?
在 Android 中,有多种进程通信机制。
首先是 Intent。它不仅可以用于启动 Activity、Service 等组件,也能在一定程度上实现进程间通信。当一个应用通过 Intent 启动另一个应用的组件时,实际上就完成了一种简单的跨进程通信。例如,应用 A 可以通过 Intent 启动应用 B 中的某个 Activity,并且可以通过 Intent 携带数据,像字符串、整数等基本数据类型,或者是实现了 Parcelable 或 Serializable 接口的复杂对象,这些数据可以在不同进程的组件之间传递。
其次是 Binder 机制。这是 Android 中最重要的进程通信方式。Binder 基于 C/S 架构,它将通信的双方分为客户端和服务器端。在 Android 系统服务和应用之间的通信大量使用了 Binder。例如,当应用调用系统的位置服务来获取位置信息时,应用就是客户端,位置服务就是服务器端。Binder 通过代理对象来实现通信,客户端通过获取服务器端的代理对象来调用服务器端的方法。这种机制的优点是高效、安全,因为它在内核空间进行了一定的优化,并且 Android 系统对每个应用都有自己的用户 ID,Binder 在通信时会进行权限检查,防止恶意应用的非法访问。
还有 Content Provider。它主要用于在不同的应用之间共享数据。一个应用可以通过 Content Provider 将自己的数据暴露出去,其他应用可以通过 ContentResolver 来访问这些数据。例如,联系人应用通过 Content Provider 将联系人数据提供给其他应用。Content Provider 可以基于数据库、文件等多种数据存储方式,访问应用可以通过 ContentResolver 的查询、插入、更新和删除等操作来和 Content Provider 交互,从而实现跨应用的数据共享和通信。
另外,还有 Socket 通信。它类似于传统的网络通信方式,通过 TCP 或 UDP 协议在不同进程之间建立连接进行通信。不过在 Android 中使用 Socket 通信需要注意权限问题,例如如果应用要进行网络通信,需要在 AndroidManifest.xml 文件中声明网络访问权限。这种通信方式比较灵活,可以用于一些复杂的、自定义的跨进程通信场景,比如开发网络应用或者和其他设备进行通信。
ArrayList 和 Vector 的对比。
ArrayList 和 Vector 都是 Java 中用于存储数据的集合类,在 Android 开发中也经常会用到。
从线程安全方面来看,Vector 是线程安全的。这是因为 Vector 的所有方法都被 synchronized 关键字修饰,这意味着在多线程环境下,多个线程同时访问和操作 Vector 对象时,会进行同步处理,保证数据的一致性。例如,当一个线程在向 Vector 中添加元素的同时,另一个线程也在读取或者删除元素,Vector 能够正确地处理这些操作,不会出现数据不一致的情况。而 ArrayList 是非线程安全的。如果在多线程环境下直接使用 ArrayList,可能会导致数据不一致或者出现并发访问异常。例如,当一个线程在对 ArrayList 进行扩容操作,而另一个线程同时在访问 ArrayList 中的元素,可能会出现索引越界等问题。
在性能方面,ArrayList 通常比 Vector 有更好的性能。这是因为 ArrayList 没有像 Vector 那样的同步开销。当进行添加、删除和访问元素等操作时,ArrayList 不需要进行额外的同步处理,所以执行速度相对较快。例如,在单线程环境下,对 ArrayList 进行大量的元素添加操作,会比同样操作的 Vector 更快。特别是在频繁的读写操作场景下,ArrayList 的优势更加明显。
从扩容机制来看,ArrayList 和 Vector 都有自己的扩容策略。ArrayList 在默认情况下,当元素数量超过其容量时,会扩容为原来容量的 1.5 倍左右。Vector 在默认情况下,当元素数量超过其容量时,会扩容为原来容量的 2 倍。这种扩容策略会影响到它们的内存使用和性能。ArrayList 的扩容相对比较温和,而 Vector 由于扩容倍数较大,可能在内存使用上会有一些波动,但在某些情况下,这种较大的扩容倍数也能减少扩容的次数。
在使用场景方面,由于 Vector 是线程安全的,适合在多线程环境下对数据安全性要求较高的情况。而 ArrayList 适合在单线程环境下,对性能要求较高,并且不需要考虑线程安全的情况,比如在一个 Activity 内部存储和处理一些临时的数据列表。
为什么扩容很多情况下都是扩容为两倍?
在很多数据结构的扩容操作中,扩容为两倍是一种比较常见的策略,这有多种原因。
从性能角度考虑,扩容为两倍可以在一定程度上平衡插入操作和内存使用。当数据结构需要扩容时,如果扩容倍数过小,比如每次只增加一个固定的小数量,那么在数据量较大时,会频繁地进行扩容操作。每次扩容都需要重新分配内存空间、复制原有元素等操作,这会带来较大的性能开销。例如,一个数组每次只扩容一个元素,当有大量元素需要插入时,可能会频繁地触发扩容,导致性能下降。而扩容为两倍可以减少扩容的频率。以数组为例,假设初始容量为 4,当插入第 5 个元素时扩容为 8,这样在插入后续元素时,就不需要马上进行下一次扩容,直到插入第 9 个元素才会再次扩容。
从空间利用角度来看,扩容为两倍可以有效地利用内存。如果扩容倍数过大,比如一次扩容为原来的 10 倍,可能会导致大量的内存空间闲置。而扩容为两倍可以在保证能够容纳更多元素的同时,相对合理地利用内存。例如,对于一个存储整数的数组,初始容量为 10,当数据量增加需要扩容时,如果扩容为 20,既可以满足当前和近期的数据增长需求,又不会浪费过多的内存。而且,在计算机的内存管理中,以 2 的倍数进行内存分配也有一定的便利性,因为计算机的内存地址是按照二进制来组织的,以 2 的倍数扩容在内存对齐等方面可能会更有优势。
另外,从算法复杂度角度分析,扩容为两倍可以保证一些操作的平均时间复杂度在一个合理的范围内。例如,对于动态数组的插入操作,假设每次扩容为两倍,其平均时间复杂度可以维持在一个相对较低的水平。如果扩容策略不合理,可能会导致插入操作的时间复杂度在最坏情况下变得很高,影响数据结构的整体性能。
HashMap 和 HashTable、ConcurrentHashMap 的对比。
HashMap 是一个非线程安全的哈希表实现。它允许存储键值对,其中键是唯一的。在内部实现上,HashMap 使用了数组和链表(在 Java 8 之后,当链表长度达到一定程度时会转换为红黑树)来存储数据。当插入一个键值对时,首先会通过哈希函数计算键的哈希值,然后根据这个哈希值确定在数组中的位置。如果这个位置已经有元素存在,就会以链表(或红黑树)的形式将新元素插入到这个位置。HashMap 的优点是性能较高,因为它没有像 HashTable 那样的同步开销。在单线程环境下,它可以快速地进行插入、删除和查找操作。例如,在一个简单的 Android 应用中,用于存储用户配置信息,如用户名和密码等,HashMap 可以很好地完成这个任务。
HashTable 是一个线程安全的哈希表。它的所有方法都被 synchronized 关键字修饰,这保证了在多线程环境下数据的安全性。但是,这种同步机制也导致了性能的下降。因为在多线程环境下,每次对 HashTable 进行操作都需要获取锁,这会增加额外的时间开销。例如,在高并发的服务器端应用中,如果大量线程同时访问 HashTable,可能会导致线程阻塞,降低系统的运行效率。HashTable 在功能上和 HashMap 类似,也用于存储键值对,并且键不能重复。
ConcurrentHashMap 是为了解决在高并发环境下的哈希表使用问题而设计的。它采用了更加复杂的分段锁机制来实现线程安全。与 HashTable 不同,ConcurrentHashMap 不是对整个哈希表进行同步,而是将哈希表分成多个段,每个段都有自己的锁。当多个线程同时访问 ConcurrentHashMap 时,只要它们访问的是不同的段,就可以同时进行操作,这样就大大提高了并发性能。例如,在一个多线程的大数据处理应用中,ConcurrentHashMap 可以有效地处理多个线程同时对数据的插入、删除和查找操作,既保证了数据的安全性,又不会像 HashTable 那样因为过度的同步而导致性能严重下降。
当电话打进来时,当前应用会发生什么?
当电话打进来时,当前应用会受到系统的通知,然后会经历一系列的状态变化。
首先,当前正在运行的 Activity 会失去焦点,系统会调用它的 onPause 方法。在 onPause 方法中,Activity 会暂停一些正在进行的操作。例如,如果 Activity 中有动画正在播放,动画会暂停;如果有一些传感器的监听,如加速度计监听,也会暂停。这是因为系统需要将资源优先分配给来电显示和通话相关的操作。不过,此时 Activity 可能仍然是部分可见的,这取决于应用和来电显示的设置。
如果来电显示是全屏显示的,那么当前 Activity 会进一步调用 onStop 方法,进入完全停止状态。在这个状态下,Activity 所占用的资源可能会被系统进一步回收。例如,一些内存中的缓存数据可能会被清理,以腾出空间给来电相关的功能使用。当通话结束后,如果用户返回这个应用,系统会重新启动这个 Activity,会先调用 onRestart 方法,然后是 onStart 和 onResume 方法,使得 Activity 重新获取焦点并恢复到之前的状态,继续和用户进行交互。
对于后台运行的 Service,在来电时通常会继续运行,不过如果 Service 占用了大量的系统资源,如 CPU 资源或者网络资源,系统可能会对其进行一定的限制。例如,如果 Service 正在进行大量的数据下载,来电时下载速度可能会变慢,以保证通话的质量。
对于一些正在进行的异步任务,如 AsyncTask,来电可能会导致任务的暂停或者延迟。这取决于任务的优先级和系统资源的分配情况。如果任务的优先级较低,系统可能会暂停这个任务,将资源分配给来电相关的操作。当来电结束后,这些任务可能会根据具体情况恢复执行。
Android 发生 Crash 的整体流程是怎样的?
当 Android 应用发生 Crash 时,首先是在应用代码执行过程中出现了一个未处理的异常。这个异常可能来自于多种情况,比如空指针引用、数组越界、资源未找到等。
以 Java 层为例,当一个未处理的异常抛出后,它会沿着调用栈向上传播。如果在这个传播过程中没有被任何的 try - catch 块捕获,就会到达 Android 运行时环境。
在 Android 运行时环境中,系统会首先尝试去保存一些关于这个 Crash 的信息。这包括异常的类型、发生异常的位置(如具体的类名、方法名和代码行号)。这些信息会被记录到系统的日志文件中,方便后续开发者进行查看和分析。
同时,系统会停止当前正在执行的操作流程。如果 Crash 发生在一个 Activity 中,这个 Activity 会经历异常的生命周期状态。通常情况下,它会快速地从当前状态进入到被销毁的状态,系统会调用 Activity 的 onDestroy 方法。但这个过程可能不会像正常的 Activity 销毁那样进行完整的资源清理等操作,因为 Crash 是一个意外情况。
对于正在运行的服务(Service)或者其他组件,如果它们和发生 Crash 的部分有依赖关系或者正在进行交互,也可能会受到影响。例如,一个服务正在等待来自发生 Crash 的 Activity 的数据,那么这个服务可能会因为无法获取数据而出现异常或者进入等待超时的状态。
在用户界面上,应用会弹出一个系统默认的错误对话框(如果没有进行特殊的定制),告知用户应用出现了问题并已停止运行。然后,系统会将应用从当前的运行状态中移除,释放应用所占用的大部分资源,如内存、CPU 时间片等,使系统恢复到一个相对稳定的状态。
Native 层 Crash 是如何捕获的?
在 Native 层,也就是使用 C 或者 C++ 编写的代码部分,捕获 Crash 相对复杂一些。
一种常见的方法是使用信号处理机制。在 Linux(Android 是基于 Linux 内核)环境下,当程序出现一些严重错误,如段错误(访问非法内存地址)、总线错误等,系统会向程序发送一个信号。例如,当出现段错误时,系统会发送 SIGSEGV 信号。
开发人员可以通过注册信号处理函数来捕获这些信号。可以使用 signal 或者 sigaction 函数来实现信号处理函数的注册。当注册好信号处理函数后,一旦对应的信号产生,系统就会调用这个注册的信号处理函数,而不是让程序直接崩溃。
在信号处理函数中,可以进行一些错误信息的收集。比如记录发生错误时的函数调用栈信息,这可以通过一些工具库,如 Android 的 libcorkscrew 库来实现。这个库能够在 Native 层获取当前的栈帧信息,帮助确定发生错误的具体位置。
另外,也可以将错误信息存储到本地文件或者通过网络发送到服务器端,方便后续的分析。为了防止在收集错误信息过程中再次出现错误,信号处理函数应该尽量简洁和稳定。
还有一种方式是使用一些第三方的 Crash 捕获库。这些库在 Native 层进行了深度的封装,它们能够更好地处理各种复杂的 Crash 情况。例如,一些库可以在信号处理的基础上,进一步对不同类型的错误进行分类,并且提供更友好的错误报告界面和分析工具,帮助开发人员更快地定位和解决问题。
Java 层 Crash 是如何捕获的?
在 Java 层,主要是通过 try - catch 块来捕获可能出现的异常。当开发人员预知某个代码块可能会出现异常时,就可以将这个代码块放在 try 语句块中,然后在 catch 语句块中处理捕获到的异常。
例如,在进行文件读取操作时,可能会出现文件不存在或者没有权限读取的情况。可以这样编写代码:
try {
FileInputStream fis = new FileInputStream("example.txt");
// 其他文件读取操作
} catch (FileNotFoundException e) {
// 处理文件不存在的情况,如提示用户或者记录日志
Log.e("TAG", "文件不存在");
} catch (IOException e) {
// 处理其他I/O相关的异常
Log.e("TAG", "读取文件出现I/O异常");
}
除了这种手动的 try - catch 方式,还可以使用全局的异常处理器。通过实现 Thread.UncaughtExceptionHandler 接口来创建一个全局异常处理器。这个处理器可以捕获在主线程和其他线程中未被捕获的异常。
首先,需要创建一个实现了 Thread.UncaughtExceptionHandler 接口的类,在这个类的 uncaughtException 方法中,可以进行 Crash 信息的收集和处理。比如,可以将异常信息记录到日志文件中,或者将这些信息发送到服务器端,方便开发人员进行分析。
然后,将这个全局异常处理器设置为线程的默认异常处理器。可以通过 Thread.setDefaultUncaughtExceptionHandler 方法来实现。这样,当线程中出现未被捕获的异常时,就会被这个全局异常处理器捕获并处理,而不是让应用直接崩溃。
*如何做到发生异常时不会杀死 APP?
要做到发生异常时不会杀死 APP,一种重要的方法是通过良好的异常处理机制。
在 Java 层,如前面提到的,广泛使用 try - catch 块。在关键的代码部分,比如网络请求、文件操作、数据库访问等容易出现异常的地方,都应该使用 try - catch 来捕获可能出现的异常。并且在 catch 块中,不是简单地记录日志,而是尝试进行一些恢复操作。
例如,在网络请求中,如果出现连接超时的异常,不要直接让应用崩溃。可以在 catch 块中尝试重新连接,或者提示用户网络出现问题,让用户决定是否重试。对于数据库访问异常,如插入数据失败,可以检查数据的合法性,修正数据后再次尝试插入。
在 Android 中,还可以使用全局异常处理器。通过自定义的 Thread.UncaughtExceptionHandler 来捕获那些未被局部 try - catch 块捕获的异常。在这个全局异常处理器中,可以进行更复杂的恢复操作。
比如,可以弹出一个自定义的对话框,告知用户应用出现了小问题,但正在尝试恢复。然后根据异常的类型,对应用的状态进行调整。如果是视图相关的异常,如某个 Activity 中的视图加载出现问题,可以尝试重新加载视图或者切换到一个备用的视图。
另外,对于一些非致命的异常,可以采用降级策略。例如,一个功能模块因为异常无法正常工作,那就暂时禁用这个功能模块,让其他功能继续运行。同时,在后台可以尝试对这个异常进行修复,比如通过更新配置文件、重新初始化相关组件等方式。
从资源管理角度来看,当发生异常时,要确保应用的资源不会因为异常而进入混乱状态。例如,及时关闭打开的文件、释放网络连接、停止正在运行的动画等,这样可以避免因为资源泄漏等问题导致应用进一步恶化,甚至被系统杀死。
APK 包里包含哪些主要部分?
APK(Android Package)包主要包含以下几个重要部分。
首先是 AndroidManifest.xml 文件。这是 APK 包中非常关键的一个文件,它就像是应用的说明书。它包含了应用的基本信息,如应用的名称、图标、版本号等。同时,它还定义了应用的组件信息,包括 Activity、Service、Broadcast Receiver 和 Content Provider。例如,通过在 AndroidManifest.xml 文件中定义 Activity 的信息,可以确定 Activity 的启动模式、是否可以被其他应用启动等。而且,它还包含了应用所需要的权限信息,如访问网络的权限、读取外部存储的权限等,这些权限信息会在应用安装时被系统检查,以确保应用的合法性和安全性。
其次是 dex 文件。dex(Dalvik Executable)文件是 Android 应用的主要代码文件。它是将 Java 代码编译后得到的一种格式,这种格式是专门为 Android 的 Dalvik 或者 ART 虚拟机设计的。在 dex 文件中包含了应用的所有类、方法和变量等信息。当应用运行时,虚拟机就会加载这些 dex 文件来执行应用的功能。
还有就是资源文件。资源文件包括布局文件(XML 格式)、图片文件(如 PNG、JPEG 等格式)、字符串资源文件等。布局文件用于定义应用的界面布局,通过设置视图的位置、大小和属性等信息来构建用户界面。图片文件用于在应用中显示各种图标、背景等图像。字符串资源文件用于存储应用中使用的各种文字信息,这样可以方便地进行本地化,即将应用适配到不同的语言环境。
另外,APK 包还可能包含一些原生库文件(so 文件)。这些文件主要是在 Native 层使用 C 或者 C++ 编写的代码编译得到的。如果应用需要使用一些高性能的或者系统底层的功能,如音视频处理、图形渲染等,就可能会包含这些原生库文件。它们会在应用运行时被加载,以提供相应的功能。
APP 在运行时是如何获取到 layout 文件的?
在 Android 应用中,当一个 Activity 或者 Fragment 需要显示界面时,会通过 setContentView 方法来获取 layout 文件。以 Activity 为例,当 Activity 被创建时,系统会调用它的 onCreate 方法。在 onCreate 方法中,通常会调用 setContentView。这个方法有几种重载形式,其中一种是传入一个布局资源的 ID,这个 ID 是在 R.java 文件中自动生成的。
R.java 文件是 Android 开发工具在编译项目时自动生成的一个索引文件。它包含了应用中所有资源(包括布局资源、图片资源、字符串资源等)的索引。当在 layout 文件夹下创建一个 XML 布局文件后,Android 编译系统会为这个布局文件生成一个唯一的整数 ID,并将其添加到 R.java 文件的相应布局资源类别中。
当调用 setContentView 并传入布局资源 ID 时,系统会根据这个 ID 在资源文件中查找对应的布局文件。然后,Android 的布局 Inflater 机制就会开始工作。布局 Inflater 会解析 XML 布局文件,将其中定义的各种视图(如 TextView、Button 等)按照 XML 中的层次结构和属性设置创建出对应的 Java 对象。这个过程就像是把 XML 中的视图描述转换为实际可以在屏幕上显示的视图对象。
在解析 XML 布局文件时,布局 Inflater 会读取每个视图元素的属性。例如,对于一个 TextView,它会读取文本内容、字体大小、颜色等属性,并将这些属性设置到创建的 TextView 对象上。对于视图之间的布局关系,如线性布局中的排列方向、相对布局中的相对位置等,也会根据 XML 中的定义进行设置。最后,这些创建和配置好的视图对象会组成一个视图树,这个视图树就是 Activity 或者 Fragment 最终显示的界面内容。
surfaceview 跟 textview 的区别是什么?
SurfaceView 和 TextView 在功能和用途上有很大的区别。
TextView 主要用于在屏幕上显示文本信息。它有许多属性可以用来设置文本的样式,比如文本颜色、字体大小、字体类型等。通过在 XML 布局文件中或者在代码中设置这些属性,可以很方便地改变文本的外观。例如,可以通过设置 android:textColor 属性来改变文本的颜色,通过设置 android:textSize 属性来改变字体大小。
TextView 还支持文本的格式化,比如可以设置部分文本为粗体、斜体或者添加下划线等。它在布局中的位置和大小可以通过布局属性来控制,比如在相对布局中,可以设置它相对于其他视图的位置;在线性布局中,可以设置它是水平排列还是垂直排列。并且,TextView 可以自动处理文本的换行,当文本内容超过视图的宽度时,会自动换行显示,以保证文本的完整性。
SurfaceView 则主要用于进行自定义的图形绘制和显示,特别是在需要频繁地更新画面或者进行复杂的图形处理时。与 TextView 不同,SurfaceView 有自己独立的绘图表面。这使得它可以在一个单独的线程中进行绘制,而不会阻塞主线程。例如,在开发一个游戏或者视频播放应用时,使用 SurfaceView 可以实现流畅的画面更新。
SurfaceView 通过 SurfaceHolder 来控制绘图表面。可以通过获取 SurfaceHolder,然后使用 LockCanvas 方法来获取一个用于绘图的 Canvas 对象,在这个 Canvas 上进行图形绘制。它通常用于绘制一些复杂的图形,如 2D 或 3D 游戏中的角色、场景,或者视频播放中的视频帧等。而且,SurfaceView 可以通过设置透明度等属性来实现一些特殊的视觉效果,但其本身并不像 TextView 那样主要用于显示文本信息。
自定义 View 如何特殊处理 wrap_content 和 padding?
当自定义 View 时,处理 wrap_content 和 padding 需要特别注意,以确保视图能够正确地布局和显示。
对于 wrap_content,首先要理解它的含义。当视图的宽度或高度设置为 wrap_content 时,意味着视图的大小应该根据其内容自动调整。在自定义 View 中,需要在 onMeasure 方法中进行特殊处理。
在 onMeasure 方法中,需要根据视图的内容来计算合适的大小。如果是一个简单的自定义 View,比如只显示一个圆形,那么需要考虑圆形的半径等因素来计算大小。如果视图包含子视图,还需要考虑子视图的大小和布局方式。例如,假设自定义 View 是一个包含多个子视图的容器,当宽度设置为 wrap_content 时,需要遍历所有子视图,计算它们的总宽度,并考虑子视图之间的间距等因素,来确定自定义 View 的宽度。
对于 padding,它是用于在视图的内容和视图的边界之间设置间隔的。在自定义 View 中,同样需要在 onMeasure 和 onDraw 方法中进行处理。在 onMeasure 方法中,当计算视图大小时,需要考虑 padding 的影响。例如,如果视图的宽度是根据内容确定的,那么计算宽度时需要将左右 padding 的值加到最终的宽度计算结果中。
在 onDraw 方法中,当绘制视图内容时,也需要考虑 padding。比如,如果是绘制一个矩形,需要将绘制的起点根据 padding 的值进行调整,以确保内容不会绘制到 padding 区域内。如果是绘制文本,同样需要将文本的绘制位置根据 padding 进行偏移,使得文本与视图边界之间有合适的间隔,从而保证视图的整体布局和显示效果符合预期。
MVVM 和 MVP 的区别是什么?
MVVM(Model - View - ViewModel)和 MVP(Model - View - Presenter)都是软件设计模式,用于分离应用的不同职责部分,提高代码的可维护性和可测试性。
在 MVP 模式中,View 负责显示界面,用户的交互操作会被传递给 Presenter。Presenter 是中间层,它负责处理业务逻辑和数据获取。它从 Model 中获取数据,并将处理后的结果传递给 View 进行显示。例如,在一个用户登录界面中,View 负责显示用户名和密码输入框以及登录按钮。当用户点击登录按钮时,这个操作被传递给 Presenter。Presenter 会获取用户输入的用户名和密码,然后调用 Model 中的验证方法来验证用户信息。如果验证通过,Presenter 会通知 View 显示登录成功的消息;如果验证失败,Presenter 会通知 View 显示错误信息。
MVP 模式的一个特点是 View 和 Presenter 之间是双向交互的。View 需要实现一个接口,Presenter 通过这个接口来更新 View 的显示。这种方式使得 View 和 Presenter 之间的耦合度相对较高,因为它们需要相互了解对方的接口和方法。
在 MVVM 模式中,View 同样负责显示界面,ViewModel 是核心部分。ViewModel 用于处理业务逻辑和数据绑定。它会将 Model 中的数据转换为可以被 View 直接使用的形式,并且提供了数据绑定的机制。例如,在一个列表显示应用中,ViewModel 会从 Model 中获取数据列表,然后通过数据绑定将列表数据绑定到 View 中的列表视图上。
MVVM 模式的关键在于数据绑定。View 和 ViewModel 之间的交互主要是通过数据绑定来实现的。当 ViewModel 中的数据发生变化时,会自动更新 View 的显示;当 View 中的用户交互导致数据变化时,也会自动更新 ViewModel 中的数据。这种方式使得 View 和 ViewModel 之间的耦合度相对较低,因为它们不需要像 MVP 那样通过接口来进行大量的交互。
LiveData 与 RxJava 的区别是什么?
LiveData 是 Android Architecture Components 中的一个组件,主要用于在 Android 应用中实现数据的观察和响应式编程。它的设计目的是与 Android 的生命周期紧密结合。
LiveData 是基于观察者模式的。它允许一个或多个观察者(通常是 Activity、Fragment 等 UI 组件)观察数据的变化。当数据发生变化时,LiveData 会通知所有的观察者。这种通知机制是和 Android 生命周期感知的,也就是说,它只会在观察者处于活跃状态(如 Activity 处于前台)时才会发送数据更新通知。例如,在一个显示用户信息的 Activity 中,当用户信息在后台被更新后,只有当这个 Activity 处于前台时,LiveData 才会将更新后的用户信息发送给 Activity,这样可以避免在 Activity 处于后台时进行不必要的更新操作。
RxJava 是一个功能强大的响应式编程库,它不局限于 Android 平台。RxJava 通过操作符来处理数据流,它可以对各种数据源进行转换、过滤、合并等操作。例如,可以将一个网络请求的结果流通过 RxJava 的操作符进行处理,先进行数据的过滤,去除不符合要求的数据,然后进行数据的转换,将数据格式转换为适合 UI 显示的形式。
与 LiveData 不同,RxJava 没有和 Android 生命周期紧密绑定。这意味着在使用 RxJava 时,需要开发者自己处理订阅和取消订阅的操作,以避免内存泄漏等问题。例如,在一个 Activity 中使用 RxJava 进行网络请求,如果没有在 Activity 销毁时取消订阅,可能会导致内存泄漏,因为网络请求的结果可能会在 Activity 不存在的情况下继续尝试更新 UI。另外,RxJava 的操作符更加丰富和灵活,可以用于处理复杂的异步数据处理场景,而 LiveData 主要侧重于简单的、和生命周期相关的数据观察和更新。
LiveData 生命周期感知如何实现?
LiveData 实现生命周期感知主要是通过 LifecycleOwner 和 LifecycleObserver 这两个重要的概念。
在 Android 中,Activity 和 Fragment 等组件都实现了 LifecycleOwner 接口。这个接口提供了获取 Lifecycle 对象的方法。Lifecycle 对象用于跟踪组件的生命周期状态,它有多种状态,如 CREATED、STARTED、RESUMED 等。
当一个 LiveData 对象被观察时,它会检查观察者是否是一个 LifecycleObserver。如果是,LiveData 会将自身与观察者的生命周期关联起来。例如,当一个 Activity(作为 LifecycleObserver)开始观察 LiveData 时,LiveData 会通过 Activity 的 Lifecycle 来感知其状态变化。
在 LiveData 的内部实现中,它会在 Lifecycle 的各个关键状态变化时采取不同的行为。当 Activity 处于 CREATED 状态时,LiveData 可能会暂停发送数据更新通知,因为此时 Activity 还没有完全显示给用户。当 Activity 进入 RESUMED 状态,也就是在前台并且可以和用户交互时,LiveData 会开始发送数据更新通知。
这种关联是通过在 Lifecycle 中添加和移除相应的观察者来实现的。当 Activity 的生命周期状态发生变化,比如从 RESUMED 变为 PAUSED,LiveData 会收到这个状态变化的通知,然后根据预先定义的规则来处理。例如,在 PAUSED 状态下,LiveData 可能会暂停数据更新,避免在 Activity 处于后台时进行不必要的操作。
同时,LiveData 还可以处理生命周期的异常情况。比如当 Activity 在观察 LiveData 的过程中被意外销毁,LiveData 会自动清理与这个 Activity 相关的资源,避免内存泄漏等问题。这是通过在 LifecycleOwner 的生命周期结束时,自动取消观察者的注册来完成的。
Retrofit 返回的是什么数据类型?
Retrofit 是一个用于在 Android 中进行网络请求的库,它返回的数据类型主要取决于接口定义中的返回值类型。
Retrofit 支持返回多种数据类型,其中最常见的是 Call 类型。Call 是一个接口,它代表一个网络请求操作。当执行一个网络请求时,Retrofit 返回的是一个 Call 对象,这个对象可以用于控制请求的执行,如异步执行请求、取消请求等。例如,当调用一个返回用户信息的接口时,可能会得到一个 Call<UserInfo>类型的返回值,其中 UserInfo 是自定义的数据类型,用于存储用户信息。
除了 Call 类型,Retrofit 还可以通过 RxJava 的集成返回 RxJava 中的 Observable 或者 Flowable 等响应式类型。如果在项目中使用了 RxJava 来处理异步操作,那么可以将 Retrofit 的接口定义为返回这些类型。这样可以利用 RxJava 强大的操作符来处理网络请求的结果,比如进行数据过滤、转换、合并等操作。
另外,Retrofit 也可以返回简单的数据类型,如 String、Integer 等。这通常用于获取一些简单的文本信息或者状态码等。例如,当请求一个服务器的版本号时,可能会返回一个 String 类型的数据,这个字符串就是服务器的版本号。
如果返回的数据是 JSON 格式,Retrofit 可以通过使用 Gson 等解析库将 JSON 数据转换为对应的 Java 对象。例如,服务器返回一个包含用户列表的 JSON 数据,Retrofit 可以在后台将这个 JSON 数据自动解析为一个 List<User>类型的 Java 对象,然后将这个对象返回给调用者。
请求数据这个过程发生了什么?
在请求数据时,首先是构建请求。在 Android 应用中,如果使用像 Retrofit 这样的网络请求库,需要定义一个接口来描述请求的细节。这个接口包括请求的方法(如 GET、POST 等)、请求的 URL 路径、请求头信息等。例如,要请求一个用户信息接口,会在接口中定义一个方法,这个方法的注解可能会指定请求方法是 GET,路径是类似于 “/api/user/info” 这样的 URL。
在构建好请求后,会进行网络连接的建立。这涉及到通过底层的网络协议(如 HTTP 或者 HTTPS)与服务器建立连接。应用会根据设备的网络配置(如 Wi - Fi 或者移动数据),通过操作系统提供的网络功能来找到服务器的 IP 地址,并尝试与之建立 TCP 或者 UDP 连接。这个过程可能会受到网络状况的影响,比如网络延迟、网络不稳定等情况。
当连接建立后,会将请求信息发送给服务器。如果是 GET 请求,请求信息主要包含在 URL 中,包括查询参数等。如果是 POST 请求,除了 URL 外,还会将请求体中的数据发送给服务器。这些数据可能是用户输入的信息、应用的配置数据等。
服务器收到请求后,会根据请求的内容进行处理。这可能包括查询数据库、执行一些业务逻辑等操作。例如,对于一个获取用户订单信息的请求,服务器会在数据库中查找对应的订单记录,然后将这些记录进行处理,可能会进行数据格式的转换,使其适合在网络上传输。
最后,服务器会将处理后的结果返回给客户端。客户端收到返回的数据后,会根据数据的类型和应用的需求进行处理。如果是 JSON 格式的数据,可能会使用 Gson 等解析库进行解析,然后将解析后的结果更新到 UI 或者存储到本地数据库等。
get 与 post 请求的区别是什么?
GET 和 POST 是两种常见的 HTTP 请求方法,它们有很多区别。
从请求数据的位置来看,GET 请求的数据是通过 URL 传递的。具体来说,数据会以查询字符串的形式附加在 URL 的后面。例如,一个获取用户信息的 GET 请求可能是 “http://example.com/api/user?name=John&age=30”,其中 “name=John&age=30” 就是查询字符串,包含了请求的参数。这种方式使得请求的数据对用户是可见的,并且因为 URL 有长度限制,所以 GET 请求能够传递的数据量相对有限。
而 POST 请求的数据是放在请求体中的。请求体是 HTTP 请求消息的一部分,它和请求头以及请求行一起构成了完整的请求消息。POST 请求的数据在 URL 中是不可见的,这对于一些敏感信息(如用户密码)的传输比较安全。并且 POST 请求在理论上没有数据量的限制,不过实际上这个数据量会受到服务器配置和网络等因素的限制。
从请求的语义上来说,GET 请求通常用于获取资源。比如获取一篇文章的内容、获取用户列表等。它是幂等的,这意味着多次执行相同的 GET 请求应该得到相同的结果,只要服务器上的数据没有变化。
POST 请求主要用于向服务器提交数据,以创建或者更新资源。例如,向服务器提交一个新的用户注册信息、更新一篇文章的内容等。POST 请求不是幂等的,因为每次提交的数据可能不同,会导致不同的结果。
在安全性方面,由于 GET 请求的数据在 URL 中可见,所以相对不太安全。而 POST 请求虽然将数据放在请求体中,但这并不意味着它就是绝对安全的。在传输过程中,无论是 GET 还是 POST 请求的数据都可能被拦截,因此还需要使用加密协议(如 HTTPS)来确保数据的安全性。
面向对象三大特性是什么?
面向对象的三大特性是封装、继承和多态。
封装是指将数据和操作数据的方法组合在一起,并对外部隐藏数据的实现细节。通过封装,可以提高代码的安全性和可维护性。例如,在一个用户类中,用户的姓名、年龄等信息可以作为私有成员变量被封装在类的内部。同时,提供公共的方法来访问和修改这些信息,如 getName 方法用于获取姓名,setAge 方法用于设置年龄。这样,外部代码只能通过这些公共方法来操作类中的数据,而无法直接访问私有成员变量,从而避免了数据的非法访问和错误修改。
继承是一种创建新类的方式,新类(子类)可以继承现有类(父类)的属性和方法。这使得代码可以复用,并且可以建立类之间的层次关系。例如,有一个动物类,它有一些通用的属性和方法,如动物的名称、移动的方法。然后可以创建一个狗类作为动物类的子类,狗类可以继承动物类的名称属性和移动方法,同时还可以添加自己特有的属性和方法,如狗的品种属性和汪汪叫的方法。通过继承,减少了代码的重复编写,并且可以更好地组织和管理代码。
多态是指同一种行为在不同的对象上有不同的表现形式。在面向对象编程中,多态主要通过方法重写和方法重载来实现。方法重写是在子类中重新定义父类中已经存在的方法,使其具有不同的行为。例如,动物类中有一个发出声音的方法,在狗类中重写这个方法可以让狗发出汪汪叫的声音,在猫类中重写这个方法可以让猫发出喵喵叫的声音。方法重载是在同一个类中定义多个同名方法,但它们的参数列表不同,这样可以根据不同的参数调用不同的方法,从而实现多态。多态使得代码更加灵活,可以根据不同的对象类型来执行不同的操作。
Array 和 Linked 的区别是什么?
数组(Array)和链表(Linked)是两种常见的数据结构,它们有诸多不同之处。
从存储方式来看,数组是一种连续的存储结构。它在内存中占用一块连续的空间,通过索引来访问元素。例如,一个整数数组int[] array = new int[5];,这 5 个整数在内存中是依次紧密排列的。这种连续存储方式使得数组在访问元素时效率很高,通过索引可以直接定位到元素的存储位置,时间复杂度为 O (1)。但数组的大小是固定的,一旦创建,就很难进行动态扩展。如果要增加或减少数组的容量,通常需要创建一个新的数组并复制元素,这会带来一定的性能开销。
链表则是一种非连续的存储结构。它由一系列节点组成,每个节点包含数据部分和指向下一个节点(在单链表中)的指针。链表的节点可以分散在内存的不同位置。例如,一个简单的链表节点可能包含一个数据域和一个指向下一个节点的引用。链表在插入和删除元素方面比较灵活,只需要修改节点之间的指针即可。在单链表中插入一个节点的时间复杂度为 O (1)(如果已知插入位置的前驱节点),但在链表中查找一个元素需要从头节点开始逐个遍历,时间复杂度为 O (n),这是因为没有像数组那样的直接索引来定位元素。
在内存使用方面,数组由于其连续存储的特性,可能会造成内存空间的浪费。如果数组的大小定义得过大,而实际存储的元素较少,就会有部分内存闲置。而链表的节点是按需分配内存的,不会出现像数组那样的空间浪费情况,但每个节点需要额外的空间来存储指针,这也会占用一定的内存。
从应用场景来看,数组适合存储数据量固定、需要频繁访问元素的情况,比如存储一个班级学生的成绩。链表则适用于需要频繁插入和删除元素的场景,如实现一个简单的队列或者栈。
HashMap 底层实现原理是什么?
HashMap 是一种用于存储键值对的数据结构,它的底层主要是基于数组和链表(在 Java 8 及以后还引入了红黑树)来实现的。
从数据存储的基本结构来看,HashMap 内部有一个数组,这个数组的每个元素被称为桶(bucket)。当我们要存储一个键值对时,首先会通过一个哈希函数计算键的哈希值,这个哈希函数会将键的信息转换为一个整数。然后通过这个整数对数组的长度取模,得到这个键值对应存储在数组中的索引位置,也就是桶的位置。例如,对于一个简单的哈希函数,计算出键的哈希值为 10,而数组长度为 8,那么这个键值对可能会存储在索引为 2(10 % 8)的桶中。
当不同的键通过哈希函数计算后得到相同的桶索引时,就会产生哈希冲突。为了解决哈希冲突,HashMap 采用了链表(在哈希冲突较严重时会转换为红黑树来提高性能)的方式。在同一个桶中,所有哈希冲突的键值对会以链表的形式存储。当查找一个键值对时,先通过哈希函数找到对应的桶,然后在桶中的链表上逐个比较键是否相等,直到找到目标键或者遍历完整个链表。
在动态扩容方面,当 HashMap 中的元素数量达到一定的阈值(负载因子乘以数组长度)时,就会进行扩容。扩容是创建一个新的更大的数组,并将原来数组中的键值对重新计算哈希值并存储到新数组中。这个过程比较复杂,因为需要重新计算每个键值对的存储位置,但这可以保证 HashMap 在存储较多元素时依然能保持较好的性能。
在键值对的访问效率方面,理想情况下,通过哈希函数可以将键值对均匀地分布到各个桶中,这样在查找、插入和删除操作时,时间复杂度可以接近 O (1)。但如果哈希冲突严重,链表过长,操作的时间复杂度可能会退化为 O (n),这也是为什么在 Java 8 中引入红黑树来优化这种情况的原因。
进程间通信方式有哪些?
在操作系统中,进程间通信(IPC)有多种方式。
首先是管道(Pipe)。管道是一种半双工的通信方式,它主要用于具有亲缘关系的进程之间通信,比如父子进程。管道有两种类型,无名管道和有名管道。无名管道只能在具有亲缘关系的进程之间使用,它是通过在创建子进程时复制文件描述符来实现通信的。例如,父进程可以通过管道将数据发送给子进程,子进程可以从管道中读取数据。有名管道则可以在不同的进程之间使用,只要这些进程可以访问同一个有名管道的文件路径。有名管道在文件系统中有一个名字,通过这个名字不同的进程可以打开管道进行通信。
其次是消息队列(Message Queue)。消息队列是一种全双工的通信方式,它可以在多个进程之间传递消息。消息队列中的消息是有格式的,通常包含消息类型和消息内容。一个进程可以将消息发送到消息队列中,其他进程可以从消息队列中读取消息。这种方式可以实现不同进程之间的异步通信,而且消息队列可以存储多个消息,接收消息的进程可以根据消息类型等因素来选择接收哪些消息。
共享内存(Shared Memory)也是一种常见的进程间通信方式。它允许多个进程共享同一块内存区域。这些进程可以直接读写共享内存中的数据,这样可以实现高效的数据共享和通信。但是,使用共享内存需要注意同步问题,因为多个进程同时访问和修改共享内存可能会导致数据不一致等问题。通常需要配合信号量(Semaphore)或者互斥锁(Mutex)等同步机制来确保数据的正确性。
还有套接字(Socket)通信。套接字可以用于不同主机上的进程之间通信,也可以用于同一主机上的进程间通信。它基于网络协议,如 TCP/IP 或者 UDP 协议。通过套接字,进程可以建立连接,发送和接收数据,这种方式比较灵活,适用于复杂的网络应用场景,如网络服务器和客户端之间的通信。
equals 和 == 的区别是什么?
在 Java 中,equals和==是用于比较的操作符,但它们有着不同的含义和用途。
==是一个比较基本类型或者引用类型的操作符。当用于比较基本类型(如int、double、char等)时,它比较的是两个值是否相等。例如,int a = 5; int b = 5;,那么a == b的结果为true,因为它们的值是相同的。当用于比较引用类型(如对象)时,==比较的是两个对象的引用是否相同,也就是它们是否指向内存中的同一个对象。例如,Object obj1 = new Object(); Object obj2 = obj1;,那么obj1 == obj2为true,因为它们指向同一个对象;但如果Object obj3 = new Object();,那么obj1 == obj3为false,因为它们是不同的对象引用。
equals方法主要用于比较对象的内容是否相等。它是在Object类中定义的一个方法,默认的实现和==比较引用是相同的。但很多类会重写equals方法来定义自己的比较逻辑。例如,在String类中,equals方法比较的是两个字符串的字符序列是否相同。String str1 = "hello"; String str2 = "hello";,那么str1.equals(str2)为true,因为它们的字符序列相同,尽管它们可能是不同的对象引用。
在自定义类中,如果想要比较对象的内容而不是引用,就需要重写equals方法。在重写equals方法时,需要遵循一些规则,比如自反性(x.equals(x)应该为true)、对称性(如果x.equals(y)为true,那么y.equals(x)也应该为true)、传递性等。
在实际使用中,当比较基本类型的值时,通常使用==;当比较对象的内容时,应该使用equals方法,除非明确想要比较引用。
线程间加锁的方式有哪些?
在多线程编程中,为了保证数据的一致性和避免并发访问问题,需要使用加锁机制。
首先是synchronized关键字。这是 Java 中最基本的加锁方式。它可以用于方法和代码块。当用于方法时,如public synchronized void method(),表示这个方法是同步方法,在同一时刻,只有一个线程可以执行这个方法。当用于代码块时,需要指定一个对象作为锁,例如synchronized (this) { // 代码块 },这里this表示当前对象作为锁。当一个线程进入这个同步代码块时,会获取这个对象的锁,其他线程如果也想进入这个代码块,就需要等待锁被释放。这种方式简单直接,适用于简单的同步场景,比如在一个对象内部的多个方法之间需要保证顺序访问的情况。
其次是ReentrantLock。这是java.util.concurrent.locks包中的一个类,它提供了比synchronized更灵活的锁机制。ReentrantLock可以实现可重入锁,即一个线程可以多次获取同一个锁。它有一些高级的特性,比如可以设置公平锁和非公平锁。公平锁是指按照线程请求锁的顺序来分配锁,非公平锁则不保证这个顺序,可能会导致某些线程优先获取到锁。例如,在一个资源竞争激烈的场景中,使用公平锁可以保证每个线程都有公平的机会获取锁,而使用非公平锁可能会提高系统的整体性能,但可能会让某些线程等待时间过长。
还有ReadWriteLock,它是一个接口,有ReentrantReadWriteLock这个实现类。ReadWriteLock用于处理读写操作的并发问题。它将锁分为读锁和写锁。多个线程可以同时获取读锁,因为读操作通常是不会互相干扰的。但是,当一个线程获取写锁时,其他线程无论是读锁还是写锁都不能获取,直到写锁被释放。这种方式适用于数据读取频繁但写入不频繁的场景,比如一个数据缓存系统,多个线程可以同时读取缓存中的数据,但当需要更新缓存数据时,需要获取写锁来保证数据的一致性。
JVM 内存结构是怎样的?
JVM(Java 虚拟机)内存结构主要分为以下几个部分。
首先是程序计数器(Program Counter Register)。它是一块较小的内存空间,用于存储当前线程所执行的字节码指令的行号指示器。在多线程环境下,每个线程都有自己独立的程序计数器,因为线程是轮流切换执行的,通过这个计数器可以保证线程切换后能恢复到正确的执行位置。例如,当线程 A 执行到一个方法的中间部分,被切换出去,之后再切换回来时,程序计数器就能让线程 A 继续从之前中断的位置执行。
其次是 Java 虚拟机栈(Java Virtual Machine Stacks)。每个线程在创建时都会创建一个自己的虚拟机栈。它用于存储局部变量表、操作数栈、动态链接、方法出口等信息。局部变量表用于存放方法参数和方法内部定义的局部变量。当一个方法被调用时,就会在栈中创建一个栈帧,用于存储这个方法的这些信息。方法执行完毕后,栈帧就会被弹出。例如,在一个递归方法调用中,每一层递归都会在栈中创建一个新的栈帧,当递归过深时,可能会导致栈溢出(Stack Overflow)。
然后是本地方法栈(Native Method Stacks)。它和 Java 虚拟机栈类似,不过它是用于为本地方法(使用非 Java 语言,如 C 或 C++ 编写并通过 JNI 调用的方法)服务的。本地方法栈的具体实现可能因 JVM 的不同而不同,但作用和虚拟机栈类似,也是存储方法调用的相关信息。
堆(Heap)是 JVM 内存中最大的一块区域,用于存储对象实例。所有线程共享堆内存。在堆中,对象的创建和销毁是比较频繁的操作。例如,通过new关键字创建的对象都会在堆中分配空间。堆又可以细分为新生代(Young Generation)和老年代(Old Generation),新生代用于存放新创建的对象,老年代用于存放经过多次垃圾回收后依然存活的对象。垃圾回收器(Garbage Collector)主要负责回收堆中不再使用的对象,以释放内存空间。
最后是方法区(Method Area)。它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等。例如,一个类的字节码信息、类中的常量池信息都会存储在方法区。在 Java 8 之后,方法区的实现由永久代(PermGen)改为元空间(Metaspace),元空间使用本地内存,而不是像永久代那样受限于 JVM 内存,这样可以避免一些因为永久代内存不足导致的问题。
动态代理设计模式是什么?
动态代理是一种设计模式,它允许在运行时创建代理对象来代替真实对象进行操作。
在动态代理中,有三个主要的角色:代理对象、真实对象和接口。接口定义了真实对象和代理对象都需要实现的方法。真实对象是实际执行业务逻辑的对象。代理对象则是在真实对象的基础上,通过动态生成的方式创建出来的,用于在调用真实对象的方法之前或之后添加额外的操作。
动态代理的实现通常依赖于反射机制。以 Java 为例,在 Java 的java.lang.reflect包中有相关的类来实现动态代理。当需要创建一个代理对象时,首先要定义一个接口,真实对象实现这个接口。然后通过Proxy类的newProxyInstance方法来创建代理对象。这个方法需要传入类加载器、接口数组和一个调用处理器(InvocationHandler)。
调用处理器是动态代理的核心部分。它实现了InvocationHandler接口,这个接口只有一个invoke方法。当代理对象的某个方法被调用时,实际上是调用了调用处理器的invoke方法。在invoke方法中,可以在调用真实对象的方法之前或之后添加额外的逻辑。例如,可以在方法调用前进行权限检查,或者在方法调用后记录日志。
动态代理的一个重要应用场景是在 AOP(Aspect - Oriented Programming,面向切面编程)中。在 AOP 中,可以通过动态代理在不修改原有业务逻辑代码的基础上,添加一些横切面的功能,如事务管理、性能监控等。比如,在一个业务方法执行前开启事务,在方法执行后提交或回滚事务,这些操作可以通过动态代理来实现,使得业务代码和事务管理代码分离,提高代码的可维护性和可扩展性。
线程同步机制有哪些?volatile 关键字如何实现可见性?
线程同步机制有多种方式。
首先是synchronized关键字。它可以用于方法和代码块。当用于方法时,整个方法体就是一个同步块。当用于代码块时,需要指定一个对象作为锁。当一个线程进入同步块时,它会获取这个对象的锁,其他线程如果也想进入这个同步块,就需要等待锁被释放。这种方式保证了在同一时刻只有一个线程可以访问被synchronized修饰的代码块或方法,从而实现了线程之间的同步。
其次是ReentrantLock。这是一个可重入锁,它提供了比synchronized更灵活的锁机制。它可以实现公平锁和非公平锁。通过lock方法来获取锁,unlock方法来释放锁。在try - finally语句块中使用ReentrantLock是比较好的做法,以确保锁一定会被释放。
还有Semaphore(信号量)。信号量可以控制同时访问某个资源的线程数量。例如,通过设置信号量的许可数量为 3,那么最多只有 3 个线程可以同时访问这个资源。线程通过acquire方法获取许可,如果没有许可则等待,通过release方法释放许可。
CountDownLatch也是一种同步工具。它用于让一个或多个线程等待其他线程完成操作。例如,主线程可以通过CountDownLatch等待多个子线程完成任务,子线程完成任务后通过countDown方法减少计数器的值,当计数器的值为 0 时,主线程就可以继续执行。
对于volatile关键字实现可见性,它主要是通过强制从主内存读取和写入变量来实现的。在多线程环境下,每个线程都有自己的工作内存,变量的值可能会被缓存在工作内存中。当一个变量被声明为volatile时,线程对这个变量进行写操作时,会直接将值刷新到主内存中,而不是仅仅更新工作内存中的副本。当线程对这个变量进行读操作时,会直接从主内存中读取,而不是读取工作内存中的副本。这样就保证了不同线程对这个变量的操作是可见的,即一个线程对变量的修改,其他线程可以立即看到。
synchronize 如何实现可见性?volatile 如何防止指令重排?
synchronize实现可见性主要是通过它的内存语义。当一个线程进入synchronized块时,它会获取锁,这个动作会将工作内存中的变量刷新到主内存中。当线程退出synchronized块时,会将主内存中的变量更新到工作内存中,并且会通知其他等待这个锁的线程,它们可以获取最新的变量值。
例如,在一个包含共享变量的方法被synchronized修饰后,当一个线程修改了这个共享变量并退出方法,下一个获取这个锁的线程就能够看到这个变量的最新值。这种内存刷新和更新的机制保证了在synchronized块内对共享变量的操作对其他线程是可见的。
对于volatile防止指令重排,它是通过内存屏障(Memory Barrier)来实现的。指令重排是编译器和处理器为了提高程序的执行效率,可能会对指令的执行顺序进行重新排列。但是在多线程环境下,这种重排可能会导致程序出现逻辑错误。
volatile关键字在变量的读写操作前后会插入内存屏障。在写操作时,会在写操作之前插入一个写屏障,保证在这个屏障之前的所有写操作都已经完成并且刷新到主内存中,然后进行变量的写操作,之后再插入一个读屏障,保证在这个屏障之后的读操作都能读取到最新的值。在读操作时,会在读操作之前插入一个读屏障,保证读取到的是最新的值,之后再插入一个写屏障,保证在这个屏障之后的写操作不会提前到这个读操作之前执行。通过这些内存屏障的插入,volatile变量能够防止指令重排,保证程序在多线程环境下的正确性。
操作系统内核态与系统态的了解,为什么要有内核态、系统态的存在?
在操作系统中,通常有用户态(User Mode)和内核态(Kernel Mode),也有说法是系统态(System Mode),其实和内核态类似,它主要是为了保护操作系统的核心部分和管理系统资源。
用户态是指应用程序运行的状态。在这个状态下,应用程序只能访问自己的内存空间和一些允许访问的系统资源。应用程序通过系统调用(System Call)来请求操作系统提供的服务,如文件读写、网络通信等。例如,一个普通的文本编辑器应用,它在用户态运行,当它需要保存文件时,会通过系统调用请求操作系统将文件写入磁盘。
内核态是操作系统内核运行的状态。内核是操作系统的核心部分,它负责管理系统的各种资源,包括 CPU、内存、设备等。在这个状态下,操作系统可以访问和控制所有的系统资源。当应用程序通过系统调用请求服务时,CPU 会从用户态切换到内核态,由内核来执行相应的操作。
之所以要有内核态和用户态的划分,主要是为了系统的安全性和稳定性。如果没有这种划分,应用程序就可以随意访问和修改系统资源,可能会导致系统崩溃或者数据丢失。例如,一个恶意的应用程序可能会修改其他应用程序的内存空间或者直接操作硬件设备,这会对整个系统造成严重的破坏。
通过划分内核态和用户态,内核可以对系统资源进行有效的管理和保护。内核可以控制哪些资源可以被应用程序访问,以及以什么样的方式访问。同时,这种划分也有利于系统的维护和扩展。不同的操作系统服务可以在内核态进行集中管理,提高系统的性能和可靠性。例如,内存管理模块可以在内核态有效地分配和回收内存,文件系统模块可以管理文件的存储和访问,设备驱动程序可以在内核态与硬件设备进行通信,确保硬件设备的正常运行。
Linux 信号有哪些?sigill 信号在什么情况下会触发?
Linux 系统中有众多的信号,常见的信号包括以下这些。
SIGHUP 信号,通常在终端关闭或者控制进程终止时会被触发,比如当用户关闭了启动某个守护进程的终端会话,该守护进程可能会收到这个信号,用于提示它进行一些相应的处理,像重新读取配置文件等操作。
SIGINT 信号,一般是当用户在终端按下 Ctrl + C 组合键时产生,用于中断正在运行的程序,让程序可以进行一些清理工作后正常退出。
SIGKILL 信号,它是强制终止信号,能立即终止目标进程,无论进程当前处于何种状态,都无法捕获或者忽略这个信号,常用于需要强行结束出现问题或者失控的进程情况。
SIGTERM 信号,相对温和些,是一种请求终止进程的信号,进程可以捕获这个信号并进行一些收尾工作,比如释放资源、保存数据后再退出。
SIGSEGV 信号,在程序出现非法内存访问,例如访问了不属于自己的内存地址、对空指针进行解引用等情况时触发,它往往意味着程序出现了严重的错误,可能导致崩溃。
SIGILL 信号,它主要在程序执行了一条非法指令时触发。比如当可执行文件被损坏,里面包含了不符合处理器架构规范的机器码指令;或者程序在运行时由于某种错误导致跳到了无效的内存地址去执行代码,而这个地址上的内容并不是合法的指令;又或者是在进行一些代码混淆、动态生成代码等操作时出现失误,生成了不符合要求的指令,都会使得这个信号被触发,进而使程序出现异常行为甚至崩溃。
匿名内部类如何持有外部类对象?
在 Java 中,匿名内部类能够持有外部类对象是通过一种隐式的机制来实现的。
当创建一个匿名内部类时,编译器会自动为这个匿名内部类添加一个指向外部类对象的引用。从语法层面来看,比如在外部类中有一个方法,在这个方法内部创建了匿名内部类,这个匿名内部类往往会使用到外部类的成员变量或者方法等。
以一个简单的例子来说明,假设有一个外部类 Outer,里面有一个整型变量 num,并且有一个方法 test,在 test 方法里创建了匿名内部类实现了某个接口,在匿名内部类中访问了这个 num 变量。
class Outer {
int num = 10;
void test() {
// 创建匿名内部类
new SomeInterface() {
void someMethod() {
System.out.println(num);
}
}.someMethod();
}
}
在上述代码中,匿名内部类实例化并调用 someMethod 方法时能访问到外部类的 num 变量,就是因为编译器会在匿名内部类的内部自动生成一个对外部类 Outer 当前对象的引用,虽然在代码中看不到这个引用的显式声明。
从原理上来说,这是 Java 语言为了方便在内部类中方便地访问外部类的状态而设计的机制。因为内部类在很多情况下和外部类是紧密关联的,通过持有外部类对象的引用,内部类能够自然地使用外部类的各种属性和调用外部类的方法,实现代码的灵活组织以及逻辑的紧密耦合,使得一些需要在特定场景下进行的功能扩展、回调等操作可以方便地通过匿名内部类来实现,同时又能方便地与外部类交互。
内存泄漏的概念、场景及检测工具。
内存泄漏指的是程序中已经不再使用的内存空间,由于某些原因没有被及时释放,导致这部分内存一直被占用,随着程序的运行,可用内存会越来越少,严重情况下可能导致程序出现性能问题甚至崩溃。
常见的内存泄漏场景有很多。
一是单例模式使用不当引发的内存泄漏。比如单例类中持有了对某个 Activity 的引用,当这个 Activity 应该被销毁时,由于单例一直存活,导致 Activity 无法被垃圾回收,从而造成内存泄漏。因为单例的生命周期通常是和整个应用一样长,只要它持有了不该持有的对象引用,就会阻碍对象的正常回收。
二是静态变量引起的内存泄漏。如果将一个对象赋值给静态变量,而这个对象后续不再使用了,但是由于静态变量的生命周期是整个类加载到卸载的过程,所以这个对象就一直无法被回收。例如,在一个工具类中定义了静态的集合变量,往里面添加了很多对象,后续忘记清理,这些对象就会一直占用内存。
三是非静态内部类的匿名实例作为静态变量。非静态内部类会隐式持有外部类的引用,当把这样的内部类实例作为静态变量时,外部类即使应该被销毁了,也会因为内部类的静态引用而无法被回收,就像在 Activity 中定义了匿名内部类作为静态变量,当 Activity 生命周期结束时,可能出现内存泄漏。
四是资源未正确关闭。比如打开了文件流、数据库连接等资源后,没有在合适的地方进行关闭操作,这些资源所占用的内存就无法释放,久而久之就造成内存泄漏。
在检测工具方面,有 LeakCanary 这个常用的工具。它可以自动检测 Android 应用中的内存泄漏情况。在应用运行时,当有对象应该被回收但没有被回收时,LeakCanary 会进行分析并生成详细的泄漏报告,报告中会指出是哪个对象发生了泄漏、泄漏的引用链等信息,方便开发人员快速定位问题所在。还有 MAT(Memory Analyzer Tool),它是基于 Eclipse 的内存分析工具,功能强大,可以分析 Java 堆内存的使用情况,通过查看对象的引用关系、直方图等方式来查找内存泄漏点,帮助开发者解决内存相关的疑难问题。
handler 原理,sendMessage 与 sendMessageDelay 如何保证 Message 放入 MessageQueue 中的顺序?
Handler 机制主要用于在 Android 中实现线程间的通信,尤其是主线程和子线程之间传递消息。
Handler 的核心组件包括 Handler 对象、MessageQueue、Looper 以及 Message。Message 是消息的载体,里面包含了消息的内容、要发送到的目标 Handler 等信息。MessageQueue 则是一个消息队列,用于存储 Message 对象,并且这些消息会按照一定的顺序排列等待被处理。Looper 是一个循环器,它会不断地从 MessageQueue 中取出消息,并根据消息对应的目标 Handler,将消息分发给相应的 Handler 去处理。Handler 就是用于发送和接收消息的对象,在具体的线程中创建并使用。
当调用 Handler 的 sendMessage 方法时,它实际上是将一个 Message 对象添加到 MessageQueue 中。而 sendMessageDelay 方法和 sendMessage 类似,只不过它可以设置一个延迟时间,也就是这个消息会在延迟指定的时间后才被放入 MessageQueue 中可被处理的队列里。
它们保证 Message 放入 MessageQueue 中的顺序主要基于以下原理。MessageQueue 内部维护了一种有序的存储结构,消息会按照发送时间的先后顺序来排列。对于 sendMessage 方法直接发送的消息,会根据当前的时间顺序立即放入队列中合适的位置。而对于 sendMessageDelay 方法发送的带有延迟时间的消息,系统会根据其设定的延迟时间以及当前的时间基准,计算出它应该在未来的哪个时间点放入可处理的队列中。
例如,有三个消息,消息 A 通过 sendMessage 发送,消息 B 通过 sendMessageDelay 设置延迟 2 秒发送,消息 C 通过 sendMessageDelay 设置延迟 5 秒发送。那么消息 A 会立即放入队列中靠前的位置,消息 B 会在 2 秒后被放入队列中合适的位置(前提是这期间没有其他更早该放入的消息),消息 C 则会在 5 秒后被放入队列相应位置,这样就保证了不同时间发送的消息能按照期望的顺序在 MessageQueue 中排列,进而后续被 Looper 按顺序取出并由相应的 Handler 处理。
两个 Message 相隔 5 秒,线程会一直阻塞吗?
在 Handler 机制下,当有两个 Message 相隔 5 秒发送时,线程通常不会一直处于阻塞状态。
Looper 在不断地循环从 MessageQueue 中获取消息来处理,当没有消息可处理时,它会进入阻塞等待的状态,等待新消息的到来,但这个阻塞并不是那种一直占用 CPU 资源、让线程完全无法做其他事的阻塞。
例如,第一个消息已经被处理完了,而第二个消息设置了相隔 5 秒发送,在这 5 秒内,如果 Looper 所在的线程没有其他额外的任务,它就会处于阻塞等待状态,等待第二个消息到达可处理的时间点。不过,若线程中还有其他代码逻辑或者可以响应的事件等,比如在主线程中,系统还可以处理用户的触摸操作、按键操作等,这些会被系统包装成相应的消息放入 MessageQueue 中,Looper 发现有新的消息进来(不管是不是那相隔 5 秒的第二个消息),就会及时唤醒并处理新的消息。
并且,在 Android 系统的设计中,这种等待机制是高效且灵活的,旨在合理利用系统资源的同时,保证消息能够按照顺序被处理。所以,只是单纯因为两个 Message 有时间间隔而让线程一直阻塞的情况是不会出现的,线程会根据 MessageQueue 中的消息情况以及系统的其他事件动态地进行等待、唤醒以及处理等操作,确保整个应用的运行流畅性以及对各种情况的响应能力。
Java 构造器工作过程是怎样的?
在 Java 中,构造器用于创建对象并进行对象的初始化。
当通过new关键字调用一个类的构造器时,首先会进行内存分配。在堆内存中为对象分配足够的空间,这个空间大小是根据对象的成员变量以及对象的布局等因素确定的。例如,对于一个包含多个基本类型成员变量和对象引用的类,会根据这些成员变量的类型和数量计算出所需的内存空间大小,然后在堆中开辟相应的空间。
接着,会将分配的内存空间初始化为默认值。对于基本类型,如int会初始化为 0,boolean会初始化为false,char会初始化为'\u0000'等。对于引用类型,则初始化为null。这一步是为了确保对象在初始化之前成员变量有一个确定的初始状态。
然后,构造器中的代码开始执行。如果构造器有参数,会将传入的参数进行赋值操作或者根据参数进行一些计算来初始化成员变量。例如,一个类有一个带有int参数的构造器,在构造器内部可以将这个参数赋值给一个成员变量。而且,构造器还可以调用其他的方法来进行更复杂的初始化操作,比如读取配置文件、建立数据库连接等,这些操作可以帮助构建一个完整的、可用的对象。
如果一个类没有显式地定义构造器,Java 编译器会自动为其生成一个默认的无参构造器。这个默认构造器会执行上述的初始化操作,将成员变量初始化为默认值。如果类中已经定义了构造器,编译器就不会再自动生成默认构造器。在继承关系中,子类的构造器会默认调用父类的构造器。如果没有显式地调用父类构造器,编译器会在子类构造器的第一行自动插入一个对父类无参构造器的调用,以确保父类部分也能正确地初始化。
Java 中的引用类型有哪些?
在 Java 中,引用类型主要有以下几种。
强引用(Strong Reference)是最常见的引用类型。当通过new关键字创建一个对象并将其赋值给一个变量时,就形成了强引用。例如,Object obj = new Object();,这里的obj就是对新创建的Object类对象的强引用。只要强引用存在,垃圾回收器就不会回收被引用的对象。这种引用类型保证了对象在其作用域内的稳定性,适用于大多数需要长期持有对象的情况,比如在一个方法内部创建的对象,只要方法没有结束,这个对象就不会被回收。
软引用(Soft Reference)相对较弱。它所引用的对象在内存足够的情况下不会被垃圾回收,但是当内存不足时,就可能会被回收。软引用通常用于实现缓存。例如,在一个图片加载应用中,可以用软引用来存储已经加载过的图片。当内存紧张时,这些图片对象可能会被回收,但是如果内存充足,就可以直接从软引用中获取图片对象,避免重复加载,提高应用的性能。
弱引用(Weak Reference)比软引用更弱。它所引用的对象一旦没有了强引用,就会被垃圾回收,不管内存是否充足。弱引用主要用于一些特殊的场景,比如在实现 WeakHashMap 时就用到了弱引用。WeakHashMap 中的键是弱引用类型,当一个键对象没有其他强引用时,这个键值对可能会被自动清除,这在一些需要自动清理过期数据的场景中非常有用。
虚引用(Phantom Reference)是最弱的一种引用类型。虚引用的对象在任何时候都可能被垃圾回收。虚引用主要用于在对象被回收时接收一个系统通知,它必须和引用队列(Reference Queue)一起使用。当一个对象被垃圾回收时,与之关联的虚引用会被放入引用队列中,开发人员可以通过检查引用队列来获取对象被回收的信息,这种引用类型在一些资源清理或者对象生命周期监控等复杂场景中有一定的应用。
垃圾回收机制及 final 的用法。
垃圾回收(Garbage Collection,简称 GC)是 Java 语言的一个重要特性,用于自动管理内存,回收不再被使用的对象所占用的内存空间。
垃圾回收器会定期或在内存不足等情况下启动,它通过一系列复杂的算法来判断哪些对象是可以被回收的。其中,引用计数法是一种简单的算法思路,即每个对象有一个引用计数器,当有一个引用指向这个对象时,计数器加 1,当引用失效时,计数器减 1,当计数器为 0 时,就表示这个对象可以被回收。不过 Java 并没有完全采用这种方法,因为它存在循环引用的问题。Java 主要采用的是可达性分析算法,从一组被称为 “GC Roots” 的对象开始,沿着对象引用链向下搜索,如果一个对象到 GC Roots 没有任何引用链相连,就判定这个对象是可回收的。GC Roots 包括虚拟机栈中的局部变量、本地方法栈中的变量、方法区中的静态变量等。
当垃圾回收器确定要回收一个对象时,会先调用对象的finalize方法(如果这个对象重写了finalize方法),这是对象在被回收之前进行一些清理操作的机会。不过不推荐过度依赖finalize方法,因为它的执行时间不确定,而且如果finalize方法中出现异常或者执行时间过长,可能会影响垃圾回收的效率。
final关键字在 Java 中有多种用法。当用于修饰一个变量时,表示这个变量是一个常量,一旦被赋值后就不能再修改。例如,final int num = 10;,num的值在整个生命周期内都不能改变。当用于修饰一个方法时,这个方法不能被子类重写。比如在一个父类中有final修饰的方法,子类就不能再重新定义这个方法。当用于修饰一个类时,这个类不能被继承,例如final class MyClass,其他类就不能继承MyClass。
为什么 HashMap 在 1.8 中采用了红黑树?红黑树、平衡二叉树、二叉搜索树、满二叉树的区别是什么?
在 Java 8 中,HashMap 在一定条件下采用红黑树主要是为了优化性能。当哈希冲突比较严重时,HashMap 底层的链表会变得很长,在这种情况下,查找、插入和删除操作的时间复杂度会从理想的 O (1) 退化为 O (n)。红黑树是一种自平衡的二叉搜索树,它的时间复杂度在最坏情况下能保持在 O (logn),将链表转换为红黑树后,可以大大提高在哈希冲突严重时这些操作的性能。
红黑树是一种特殊的二叉搜索树,它除了满足二叉搜索树的特性(左子树上所有节点的值小于根节点的值,右子树上所有节点的值大于根节点的值)外,还具有以下特性。红黑树的节点是有颜色的,要么是红色,要么是黑色。根节点是黑色的,每个叶子节点(空节点)是黑色的,从一个节点到其每个叶子节点的所有路径上包含相同数目的黑色节点,而且如果一个节点是红色的,则它的子节点必须是黑色的。这些特性使得红黑树能够在插入和删除操作后通过一些旋转和变色操作来保持树的平衡,从而保证了时间复杂度的稳定性。
平衡二叉树是一个比较宽泛的概念,它的主要目的是保持树的左右子树高度差在一定范围内,使得树的高度尽可能小,从而保证各种操作的时间复杂度较低。红黑树是平衡二叉树的一种实现方式。
二叉搜索树是一种有序的数据结构,它的每个节点最多有两个子节点,并且满足左子树节点值小于根节点值,右子树节点值大于根节点值的特性。二叉搜索树的查找、插入和删除操作的时间复杂度在平均情况下是 O (logn),但在最坏情况下可能会退化为 O (n),比如当插入的数据是有序的时,二叉搜索树会退化成一条链表。
满二叉树是一种特殊的二叉树,它的所有叶子节点都在同一层,并且每个非叶子节点都有两个子节点。满二叉树的节点数和树的高度之间有固定的关系,节点数为 2^h - 1(h 为树的高度)。它主要用于理论研究和一些特定的算法场景,与红黑树、平衡二叉树和二叉搜索树在应用场景和特性上有较大的区别。
Java1.7 采用数组加链表与 1.8 采用红黑树的区别是什么?
在 Java 7 中,HashMap 的底层结构主要是数组加链表。当插入一个键值对时,首先通过哈希函数计算键的哈希值,然后对数组长度取模得到在数组中的索引位置。如果该位置没有元素,就直接插入;如果已经有元素,就以链表的形式将新元素插入到该位置对应的链表中。
这种结构在哈希冲突较少时性能很好,查找、插入和删除操作的理想时间复杂度接近 O (1)。但是当哈希冲突严重时,链表可能会变得很长,此时查找操作就需要遍历链表,时间复杂度会退化为 O (n)。而且在进行遍历操作时,需要依次访问链表中的每个节点,效率相对较低。
在 Java 8 中,当链表的长度达到一定阈值(默认为 8)时,链表会被转换为红黑树。红黑树是一种自平衡的二叉搜索树,它的查找、插入和删除操作在最坏情况下的时间复杂度为 O (logn)。相比 Java 7 中的长链表,在哈希冲突严重的情况下,红黑树能够大大提高操作的性能。
在内存占用方面,链表相对简单,每个节点只需要存储数据、下一个节点的引用等基本信息。红黑树由于其结构复杂,节点除了存储数据和子节点引用外,还可能需要存储节点颜色等信息用于维持树的平衡,所以在相同数量的元素下,红黑树可能会占用更多的内存。
从遍历的角度看,链表的遍历是线性的,按照节点的链接顺序依次访问。红黑树的遍历相对复杂一些,需要按照一定的规则(如中序遍历可以得到有序的数据序列)来访问节点,但是在进行范围查询等操作时,红黑树可以利用其有序的特性更高效地进行。总的来说,Java 8 中引入红黑树是对 Java 7 结构的一种优化,在处理哈希冲突严重的情况时优势明显。
HashMap 线程不安全的原因是什么?如何实现线程安全且效率高?
HashMap 线程不安全主要源于其内部的结构和操作机制。
在 HashMap 中,数据存储基于数组和链表(Java 8 后部分情况用红黑树),当多个线程同时对 HashMap 进行操作时,可能出现以下问题。例如在进行 put 操作时,可能有两个线程同时计算出相同的哈希桶索引,然后同时尝试向这个桶里插入元素。若线程 A 先获取了桶对应的链表头节点,正准备插入新节点时,线程 B 也获取到了同样的链表头节点,线程 B 完成插入后,线程 A 再插入,就可能导致线程 A 插入的节点丢失,破坏了 HashMap 的结构完整性。
另外,在扩容过程中,HashMap 会重新计算元素在新数组中的位置并迁移元素。如果多个线程同时触发扩容操作,可能会导致数据混乱,元素的迁移出现错误,最终使得 HashMap 的状态不一致,后续的查找、删除等操作也都会受到影响。
要实现线程安全且效率高,可以采用以下几种方式。
一是使用 ConcurrentHashMap。它采用了分段锁机制,将整个哈希表分成多个段(默认 16 个),每个段都有自己独立的锁。当多个线程同时操作时,只要它们操作的是不同的段,就可以并发进行,只有在操作同一个段内的数据时才需要等待锁的释放。这样在高并发场景下,相比对整个哈希表加锁(如 HashTable 的做法),能大大提高并发性能,兼顾了线程安全和效率。
二是通过 Collections 工具类的 synchronizedMap 方法将 HashMap 包装成线程安全的形式。不过这种方式是对整个 Map 对象加了一把大锁,在并发读写时,同一时刻只有一个线程能操作 Map,效率相对较低,适用于并发程度不高的简单场景。
三是在代码层面通过使用锁机制,比如使用 ReentrantLock 等可重入锁,手动对关键代码块(如 put、get 等操作涉及的代码块)进行加锁控制,合理规划锁的范围,保证多线程下数据的一致性,但这需要开发者对锁的使用比较熟练,避免出现死锁等问题。
对称加密与非对称加密的区别是什么?
对称加密和非对称加密是两种不同的加密方式,有着诸多区别。
从密钥的角度来看,对称加密使用的是同一个密钥进行加密和解密操作。也就是说,发送方用这个密钥把明文加密成密文发送出去,接收方收到密文后,使用相同的密钥就能将密文还原成明文。例如,常见的对称加密算法 AES,双方事先约定好一个密钥,如 “1234567890abcdef”,发送方用这个密钥加密数据,接收方用它解密。这种方式的优点是加密和解密速度快,适合对大量数据进行加密处理,因为只需要进行简单的数学运算即可完成加密和解密过程。
而非对称加密使用一对密钥,分别是公钥和私钥。公钥可以公开给任何人,用于加密信息;私钥则由持有者严格保密,用于解密用公钥加密后的信息。例如,在使用 RSA 算法时,发送方获取接收方的公钥,用这个公钥对要发送的消息进行加密,然后发送出去,接收方收到密文后,使用自己的私钥才能将密文解密成明文。
在安全性方面,对称加密的安全性依赖于密钥的保密性,一旦密钥泄露,加密的数据就很容易被破解。而非对称加密相对更安全一些,因为公钥是公开的,即使公钥被他人获取,没有对应的私钥也无法解密信息,不过非对称加密的算法通常比较复杂,加密和解密的速度相对较慢,不适用于大量数据的加密。
从应用场景来看,对称加密常用于对文件、数据库中大量数据的加密存储,或者在网络通信中对数据流量较大的部分加密传输等情况。非对称加密则更多用于数字签名、身份验证以及在网络通信中安全地交换对称加密的密钥等场景,确保通信双方的身份合法性以及后续通信数据的安全。
TCP 三次握手及四次挥手的原理是什么?在此过程中有哪些不安全性?针对这些不安全性的攻击手段及防御手段有哪些?
TCP 三次握手的原理如下:
首先是第一次握手,客户端向服务器发送一个带有 SYN(同步序列号)标志的 TCP 报文段,这个报文段中包含客户端随机生成的初始序列号(Sequence Number),目的是向服务器表明客户端想要建立连接,并请求服务器进行同步。
接着第二次握手,服务器收到客户端的 SYN 报文后,会回复一个 SYN + ACK 报文段。其中,SYN 标志表示服务器同意建立连接,也会生成自己的初始序列号;ACK 标志表示对客户端 SYN 报文的确认,并且 ACK 的值是客户端的初始序列号加 1,表明服务器已经收到了客户端的请求并准备好建立连接。
然后第三次握手,客户端收到服务器的 SYN + ACK 报文后,会向服务器发送一个 ACK 报文段,ACK 的值是服务器的初始序列号加 1,以此来确认服务器的回应,经过这三次握手后,TCP 连接就正式建立了,可以进行数据的传输。
TCP 四次挥手的原理如下:
当客户端想要关闭连接时,会发送一个 FIN(结束标志)报文段给服务器,表明客户端没有数据要发送了,请求关闭连接。
服务器收到客户端的 FIN 报文后,会回复一个 ACK 报文段,表示已经收到客户端的关闭请求,但此时服务器可能还有数据没发送完,所以先回复确认。
等服务器发送完剩余的数据后,会再发送一个 FIN 报文段给客户端,告知客户端自己也准备好关闭连接了。
最后客户端收到服务器的 FIN 报文后,会回复一个 ACK 报文段进行确认,经过这四次挥手后,TCP 连接就彻底关闭了。
在这个过程中的不安全性及相关情况如下:
在三次握手阶段,存在 SYN Flood 攻击。攻击者会大量发送伪造的带有 SYN 标志的报文段,使得服务器不断为这些虚假连接分配资源(如创建连接相关的数据结构、预留内存等),最终导致服务器资源耗尽,无法正常处理合法的连接请求。防御手段主要有设置 SYN 缓存队列的大小,当队列快满时,对新的 SYN 请求进行限制;采用 SYN Cookie 技术,服务器在收到 SYN 请求时,不立即分配资源,而是通过一种算法计算出一个 Cookie 值返回给客户端,后续验证通过后才真正建立连接,以此减少资源消耗。
在四次挥手阶段,可能出现 TIME - WAIT 状态相关的问题。客户端在发送最后一个 ACK 报文后进入 TIME - WAIT 状态,会等待一段时间(一般是 2MSL,MSL 是最长报文段寿命)才真正关闭连接。如果有大量的 TIME - WAIT 连接存在,会占用系统资源,影响新连接的建立。攻击者可能利用这一点发起资源耗尽攻击。防御手段可以通过调整系统参数,适当缩短 TIME - WAIT 时间,或者采用一些优化的网络协议栈实现,更好地管理 TIME - WAIT 状态的连接。
另外,在整个 TCP 连接过程中,报文段可能被中间人拦截、篡改等。攻击者可以通过 ARP 欺骗等手段冒充客户端或服务器,获取、修改传输的数据。防御这种情况可以采用 IPSec 等网络层安全协议,或者在应用层使用加密协议(如 HTTPS 取代 HTTP)来保证数据的完整性和保密性。
HTTP 重定向的原理是什么?
HTTP 重定向是一种机制,用于告知客户端(通常是浏览器)需要访问另一个不同的 URL 来获取所需的资源。
当客户端向服务器发送一个 HTTP 请求时,服务器收到请求后,根据自身的业务逻辑或者配置等情况,判断这个请求应该被重定向到另一个 URL。例如,一个网站可能进行了域名更换或者资源迁移,原来的某个页面的 URL 发生了变化,当客户端访问旧的 URL 时,服务器会返回一个重定向响应,这个响应的状态码通常是 301(永久重定向)或者 302(临时重定向)等。
以 301 永久重定向为例,服务器返回的响应头部中会包含一个 Location 字段,这个字段的值就是客户端应该重新请求的新 URL。客户端(如浏览器)收到这个带有重定向状态码和新 URL 的响应后,会根据 HTTP 协议的规定,自动发起一个新的 HTTP 请求,去访问 Location 字段中指示的新 URL,然后服务器再根据这个新的请求来提供相应的资源或者进行后续处理。
302 临时重定向类似,不过它表示此次重定向只是临时的,客户端下次再请求相同的原始 URL 时,服务器可能会有不同的响应,不一定还是重定向。
在实际应用中,比如网站升级维护时,可能会把用户对某些页面的请求临时重定向到一个维护提示页面;或者在电商网站中,商品分类页面进行调整后,旧的分类页面 URL 访问时会被永久重定向到新的分类页面 URL,这样能保证用户可以顺利获取到想要的资源,同时也便于网站对资源进行合理的管理和更新。
HTTPS 协议是如何加密的?与 HTTP 有何区别?
HTTPS 协议的加密过程涉及多个关键步骤。
首先,客户端发起一个 HTTPS 请求,连接到服务器的 443 端口(默认)。服务器收到请求后,会把自己的数字证书发送给客户端。这个数字证书包含了服务器的公钥、证书颁发机构(CA)的信息等内容,并且证书是经过 CA 机构通过特定的数字签名等手段认证过的,用于证明服务器的身份合法性。
客户端收到服务器的数字证书后,会使用本地安装的 CA 根证书来验证服务器证书的有效性。如果验证通过,客户端就从服务器证书中提取出公钥。
接下来,客户端会生成一个随机的对称加密密钥(也叫会话密钥),然后使用服务器的公钥对这个对称加密密钥进行加密,并将加密后的密钥发送给服务器。
服务器收到客户端发送的加密后的对称加密密钥后,使用自己的私钥进行解密,这样服务器和客户端就都拥有了相同的对称加密密钥。
之后,双方就使用这个对称加密密钥对后续传输的数据进行对称加密和解密操作。例如,客户端将要发送的数据用对称加密密钥加密后发送给服务器,服务器收到密文后用相同的密钥解密得到明文,反之亦然,以此实现数据的加密传输,保证数据的保密性和完整性。
与 HTTP 的区别主要体现在以下方面。
从安全性角度来看,HTTP 是明文传输协议,数据在网络传输过程中是以未加密的形式存在的,很容易被中间人拦截、窃取或者篡改,比如用户登录账号密码等敏感信息可能会被泄露。而 HTTPS 通过上述的加密机制,对传输的数据进行加密保护,确保数据的安全,有效防止中间人攻击等安全威胁。
在端口使用方面,HTTP 默认使用 80 端口进行通信,而 HTTPS 默认使用 443 端口。
从性能方面,由于 HTTPS 涉及到加密和解密操作,尤其是证书验证、密钥交换等过程会带来一定的性能开销,相比 HTTP 在传输速度上通常会稍慢一些,不过随着技术的发展,这种性能差距在不断缩小。
在搜索引擎优化(SEO)方面,搜索引擎更倾向于对使用 HTTPS 协议的网站给予更好的排名,因为其安全性更高,更能保障用户的访问体验。
**https 与 http 的区别,中间人如何修改密钥?
HTTPS 和 HTTP 的区别主要体现在以下几点。
安全性方面,HTTP 是超文本传输协议,它以明文方式传输数据。这意味着在数据传输过程中,信息是没有加密的,如用户的登录凭证、浏览内容等都可能被第三方轻易获取。而 HTTPS 是在 HTTP 的基础上加入了 SSL/TLS 加密协议,通过加密来保护数据的完整性和保密性。在 HTTPS 通信中,数据在发送端被加密,在接收端被解密,中间即使被拦截,没有相应的解密密钥也无法读取内容。
从连接建立过程来看,HTTP 的连接建立相对简单,客户端向服务器发送请求,服务器响应即可。而 HTTPS 连接建立较为复杂,除了基本的 TCP 三次握手外,还需要进行 SSL/TLS 握手。在这个过程中,服务器要向客户端发送数字证书,客户端验证证书的有效性,包括证书是否由合法的证书颁发机构(CA)颁发、证书是否过期等。验证通过后,双方协商加密算法和密钥,之后才开始正式的数据传输。
在身份验证方面,HTTP 没有提供有效的身份验证机制,服务器和客户端很难确认对方的真实身份。HTTPS 可以通过数字证书来验证服务器的身份,确保客户端连接到的是真实可靠的服务器,防止中间人伪装成服务器获取信息。
关于中间人修改密钥,在正常的 HTTPS 环境下这是非常困难的。因为在 SSL/TLS 握手过程中,密钥交换是基于非对称加密的。客户端会使用服务器的公钥来加密生成的对称加密密钥(会话密钥),这个公钥是从服务器发送的经过 CA 认证的数字证书中获取的。中间人如果要修改密钥,首先要伪造一个数字证书,让客户端信任这个假证书。但现在的 CA 认证体系比较严格,客户端通常都有 CA 根证书来验证服务器证书的合法性,很难被中间人欺骗。并且,即使中间人获取了加密后的密钥,没有服务器的私钥也无法解密获取真正的密钥,所以在正常情况下,HTTPS 能够有效防止中间人修改密钥。
http, http1.1, http2.0 的区别是什么?
HTTP 是超文本传输协议的基础版本。它是一种简单的请求 - 响应协议,主要用于在 Web 浏览器和服务器之间传输超文本,如 HTML 文件。它采用简单的文本格式来传输数据,每次请求 - 响应都建立一个新的 TCP 连接,这导致在传输多个资源(如一个网页包含多个图片、脚本等)时效率较低。
HTTP/1.1 在 HTTP 的基础上进行了改进。在连接复用方面,HTTP/1.1 支持持久连接。这意味着一个 TCP 连接可以用于多次请求 - 响应交互,不用像 HTTP 那样每次请求都建立一个新的连接,减少了连接建立和关闭的开销,提高了网络传输效率。在带宽优化上,它引入了请求头中的 Range 字段,允许客户端请求部分资源,比如只获取一个大文件的某一部分,这对于断点续传等功能的实现很有帮助。另外,HTTP/1.1 还增加了更多的请求方法,如 PUT、DELETE 等,丰富了客户端和服务器之间的交互方式。
HTTP/2.0 进一步提升了性能。它采用二进制格式传输数据,相比于 HTTP/1.1 的文本格式,二进制格式在解析和传输效率上更高,能够更有效地利用网络带宽。在多路复用方面,HTTP/2.0 实现了真正的多路复用。一个 TCP 连接上可以同时传输多个请求和响应,并且这些请求和响应之间不会互相干扰,解决了 HTTP/1.1 中管道化技术存在的队首阻塞问题。它还支持服务器推送,服务器可以主动向客户端推送一些它认为客户端可能需要的资源,比如在客户端请求一个 HTML 页面时,服务器可以同时推送这个页面可能用到的 CSS 和 JavaScript 文件,减少客户端再次请求这些文件的延迟,提高页面加载速度。
给一个数组,每个值表示坐标系中的点下标,找出组成的最长直线及相同最长长度直线的条数。
首先,对于给定的数组,我们可以把它看作是一系列点的坐标索引。假设数组为points,长度为n。
要找出最长直线,我们可以采用双重循环来遍历所有可能的点对。对于每一对点,我们可以计算出它们所确定的直线的斜率和截距(如果是垂直于 x 轴的直线,斜率不存在,我们可以特殊处理这种情况)。
具体步骤如下:
我们使用一个哈希表lineMap来存储直线的信息,其中键可以是一个自定义的表示直线的结构体(包含斜率和截距),值是一个整数,表示有多少个点在这条直线上。
从第一个点开始,遍历到倒数第二个点。对于每个点points[i],再从i + 1开始遍历到最后一个点points[j]。计算两点(points[i], points[j])所确定直线的斜率和截距。如果是垂直于 x 轴的直线,我们可以将斜率设为一个特殊值(如Double.MAX_VALUE),截距设为points[i](因为垂直于 x 轴的直线方程为x = a,这里a就是points[i])。
对于计算出的每一条直线,在哈希表lineMap中查找是否已经存在。如果存在,就将对应的计数加 1;如果不存在,就将这条直线的信息作为键插入到哈希表中,计数设为 2(因为已经找到了两个点在这条直线上)。
在遍历完所有点对后,遍历哈希表lineMap,找到计数最大的直线,这个计数就是最长直线上的点数,也就是最长直线的长度。同时,统计有多少个计数等于最长直线长度的条目,这就是相同最长长度直线的条数。
这种方法的时间复杂度是 O (n^2),因为需要双重循环遍历所有的点对。空间复杂度取决于哈希表中不同直线的数量,在最坏情况下可能是 O (n^2),但在实际情况中通常会小于这个值。
给一段数字,给出可能组成的所有 IP 地址。
假设给定的数字序列是一个字符串numStr。
要找出所有可能组成的 IP 地址,我们需要考虑 IP 地址的规则,即每个部分是 0 - 255 之间的数字,并且整个 IP 地址由四个部分组成,用点分隔。
我们可以使用回溯法来解决这个问题。回溯法是一种通过深度优先搜索来遍历所有可能解的算法。
首先,我们从字符串的开头开始,尝试选取第一个数字部分。这个部分的长度可以是 1 - 3 位数字(因为每个部分最大是 255)。但要注意,如果选取的第一个数字是 0,那么这个部分只能是 0,不能是 0 开头的多位数(如 01、02 等不符合 IP 地址规则)。
在选取第一个数字部分后,我们在剩余的字符串中继续选取第二个数字部分,同样遵循上述规则。重复这个过程,直到选取了四个数字部分。如果在选取四个数字部分后,正好用完了给定的字符串,那么这四个数字部分组成的字符串就是一个可能的 IP 地址,将其记录下来。
具体实现过程如下:
定义一个列表result来存储所有可能的 IP 地址。定义一个递归函数backtrack,这个函数接受当前已经选取的数字部分列表currentParts和剩余的数字字符串remainingStr作为参数。
在backtrack函数中,如果currentParts的长度为 4 并且remainingStr为空,那么将当前的currentParts转换为用点分隔的字符串形式,并添加到result列表中。
否则,从 1 开始,最多选取 3 位数字作为下一个数字部分。如果选取的数字部分在 0 - 255 之间并且剩余的字符串长度足够继续选取下一个部分,就将这个数字部分添加到currentParts中,然后递归调用backtrack函数,传入更新后的currentParts和剩余的数字字符串。在递归调用结束后,将刚才添加的数字部分从currentParts中移除,继续尝试下一种可能的选取方式。
最后,返回result列表,这个列表中包含了所有可能组成的 IP 地址。这种方法的时间复杂度比较复杂,因为它取决于数字序列的长度和可能的组合情况,但大致是指数级的,因为需要遍历大量的可能组合。空间复杂度主要取决于存储所有可能 IP 地址的result列表的大小,在最坏情况下可能是指数级的,不过在实际情况中通常会小很多。
gc 垃圾回收的原理及 Python 如何实现断开循环引用。
垃圾回收(GC)的原理主要是自动管理内存,释放程序中不再使用的对象所占用的内存空间。
在很多编程语言中,采用可达性分析算法来判断对象是否可回收。从一组被称为 “GC Roots” 的对象开始,沿着对象引用链向下搜索。GC Roots 包括虚拟机栈中的局部变量、本地方法栈中的变量、方法区中的静态变量等。如果一个对象到 GC Roots 没有任何引用链相连,就判定这个对象是可回收的。
另一种简单的思路是引用计数法,即每个对象有一个引用计数器,当有一个引用指向这个对象时,计数器加 1,当引用失效时,计数器减 1,当计数器为 0 时,就表示这个对象可以被回收。不过这种方法存在循环引用的问题,例如两个对象互相引用,它们的引用计数都不为 0,但实际上这两个对象可能都已经没有其他有效的引用了,应该被回收。
在 Python 中,为了处理循环引用的情况,采用了标记 - 清除(Mark - Sweep)算法。这个算法主要分为两个阶段。标记阶段,从 GC Roots 开始,递归地标记所有可达的对象。清除阶段,遍历堆内存,回收未被标记的对象。
同时,Python 还有分代回收的策略。将对象分为不同的代,新创建的对象在年轻代,经过多次垃圾回收仍然存活的对象会被移到老年代。因为通常新创建的对象更容易成为垃圾,这种分代回收可以提高垃圾回收的效率。在年轻代,垃圾回收比较频繁,使用的是复制算法,将存活的对象复制到新的内存空间,清除旧空间的所有对象。在老年代,垃圾回收频率较低,采用标记 - 清除或者标记 - 整理算法,标记 - 整理算法在标记后会将存活的对象向一端移动,然后清理边界以外的内存空间。
Integer 与 int 类型的区别及各自比较大小的方法。
在 Java 中,int是基本数据类型,而Integer是int的包装类。
int类型直接存储整数值,在内存中的存储简单直接。它占用固定的字节数(通常在 Java 中是 4 个字节),用于存储整数。例如,int num = 10;,这个num变量直接在栈内存中存储整数值 10。int类型的比较大小可以直接使用比较运算符,如==、<、>等。例如,int a = 5; int b = 10;,可以通过a < b来判断a是否小于b,这种比较是基于值的比较。
Integer是一个类,它的对象存储在堆内存中。当创建一个Integer对象时,例如Integer numObj = new Integer(10);,实际上是在堆内存中开辟了一块空间来存储这个对象,这个对象包含了一个int类型的值。Integer类型也可以通过intValue方法获取其内部存储的int值。
在比较大小方面,Integer可以使用compareTo方法来比较两个Integer对象的大小。例如,Integer num1 = new Integer(5); Integer num2 = new Integer(10);,可以通过num1.compareTo(num2)来比较它们的大小,返回值是一个整数,如果返回值小于 0,表示num1小于num2;如果返回值等于 0,表示num1等于num2;如果返回值大于 0,表示num1大于num2。
另外,在 Java 5.0 之后,有自动装箱和自动拆箱的功能。这使得int和Integer之间可以自动转换。例如,Integer num = 5;(自动装箱,将int值 5 转换为Integer对象),int value = num;(自动拆箱,将Integer对象转换为int值)。但在比较Integer对象时,使用==和compareTo方法是有区别的。==比较的是对象的引用是否相同,而compareTo方法比较的是对象内部存储的int值。
kotlin 扩展函数原理。
Kotlin 的扩展函数是一种非常方便的特性,它允许在不修改原有类的代码的情况下,为这个类添加新的函数。
从原理上来说,扩展函数实际上是一种语法糖。当定义一个扩展函数时,Kotlin 编译器会将其转换为一个静态函数,这个静态函数的第一个参数是接收者类型(也就是要扩展的类的类型)。
例如,假设有一个String类,我们定义一个扩展函数来计算字符串中某个字符出现的次数。
fun String.countChar(char: Char): Int {
var count = 0
for (c in this) {
if (c == char) {
count++
}
}
return count
}
在这个例子中,String是接收者类型。编译器在处理这个扩展函数时,会把它转换为类似于以下的 Java 静态函数(Kotlin 和 Java 可以互相转换理解):
public static int countChar(String $this, char char) {
int count = 0;
for (int i = 0; i < $this.length(); i++) {
char c = $this.charAt(i);
if (c == char) {
count++;
}
}
return count;
}
可以看到,原来的this关键字被转换为了一个函数参数$this。这样,在调用扩展函数时,就好像是在调用接收者类型的一个成员函数一样。例如,val str = "hello"; val count = str.countChar('l');,在这个调用中,str就被作为第一个参数传递给了转换后的静态函数。
扩展函数可以在不同的包中定义,这使得可以对第三方库中的类进行功能扩展。同时,扩展函数也可以被继承和重写,就像普通的函数一样。但需要注意的是,扩展函数不能访问接收者类型的私有成员,因为从本质上来说,它是一个外部的静态函数,只是语法上看起来像是成员函数。
Java 注解是什么?
Java 注解是一种元数据,它提供了一种将信息与程序元素(如类、方法、变量等)关联起来的方式,这些信息可以在编译时、类加载时或者运行时被读取和处理。
从语法上看,注解是以@符号开头的,例如@Override、@Deprecated等。注解可以有自己的元素,用于传递一些参数。例如,自定义一个注解@MyAnnotation,可以这样定义:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public class MyAnnotation {
String value;
public MyAnnotation(String value) {
this.value = value;
}
}
在这个例子中,@Target和@Retention是 Java 内置的注解,用于指定自定义注解@MyAnnotation的使用范围(这里是作用于类)和生命周期(这里是运行时可以获取)。@MyAnnotation有一个元素value,可以在使用这个注解时传递参数。
注解的作用非常广泛。在编译时,像@Override注解用于告诉编译器这个方法是重写父类的方法,编译器会检查这个方法签名是否正确重写了父类方法,如果没有,会产生编译错误。@Deprecated注解用于标记一个方法或者类已经过时,当其他代码使用这个过时的元素时,编译器会发出警告。
在运行时,可以通过反射机制来获取注解信息并进行处理。例如,一个框架可以通过扫描类上的注解来自动配置一些功能。假设我们有一个自定义的框架,通过扫描带有@MyAnnotation注解的类来进行初始化操作。可以通过反射获取类上的注解,读取注解中的参数,然后根据这些信息来执行相应的操作。这样可以实现一种基于注解的配置方式,使得代码更加灵活和可扩展。