网易下 面试
说明原码、反码、补码的概念
- 原码:是一种简单的机器数表示法。对于有符号数,最高位为符号位,0 表示正数,1 表示负数,其余位表示数值的绝对值。比如,对于 8 位二进制数,+5 的原码是 00000101,-5 的原码是 10000101。原码的优点是直观,容易理解,但在进行加减法运算时,需要根据符号位进行不同的处理,比较复杂。
- 反码:正数的反码与原码相同,负数的反码是在其原码的基础上,符号位不变,其余各位取反。例如,+5 的反码是 00000101,-5 的反码是 11111010。反码在计算机中主要用于求补码等运算,本身在实际运算中使用相对较少。
- 补码:正数的补码与原码相同,负数的补码是在其反码的基础上再加 1。以 8 位二进制数为例,+5 的补码是 00000101,-5 的补码是 11111011。补码的出现是为了解决原码在加减法运算中的问题,在计算机中,有符号数的加减法通常都是采用补码进行运算的,这样可以将减法运算转化为加法运算,简化了计算机的运算电路。
介绍你比较了解的 android 第三方框架
- Retrofit:是一个类型安全的 HTTP 客户端,用于 Android 和 Java 应用程序中进行网络请求。它使用简单,通过注解的方式来配置网络请求,比如
@GET、@POST等注解来指定请求方法,@Path、@Query等注解来传递参数。Retrofit 还支持 RxJava 等响应式编程框架,方便进行异步操作和数据处理,能很好地与 MVVM 等架构配合使用,提高代码的可维护性和可测试性。 - Glide:是一个强大的图片加载框架,主要用于在 Android 应用中加载和显示图片。它支持从网络、本地存储、资源文件等多种来源加载图片,并且具有强大的缓存机制,包括内存缓存和磁盘缓存,能有效减少图片加载的时间和流量消耗。Glide 还支持图片的裁剪、缩放、转换等多种操作,使用起来非常灵活。
- ButterKnife:是一个视图注入框架,可以通过注解的方式来绑定视图和处理点击事件等,避免了大量的
findViewById操作,使代码更加简洁、易读。例如,使用@BindView注解可以快速绑定布局中的视图,@OnClick注解可以方便地处理点击事件。
介绍 RecyclerView 的缓存机制
- RecyclerView的缓存机制主要包括四级缓存,分别是ViewHolder 缓存、Scrap 缓存、二级缓存和弱引用缓存。
- ViewHolder 缓存:当 ViewHolder 被回收时,首先会被放入 ViewHolder 缓存中。如果 RecyclerView 需要重新创建 ViewHolder,会优先从这里获取,这样可以避免重复创建 ViewHolder,提高了列表的滑动性能。
- Scrap 缓存:分为 Attached Scrap 和 Detached Scrap。Attached Scrap 用于在布局更新时临时缓存还在屏幕上的 ViewHolder,Detached Scrap 用于缓存已经从 RecyclerView 中移除的 ViewHolder。当 RecyclerView 需要重新显示这些 ViewHolder 时,可以直接从 Scrap 缓存中获取,而不需要重新绑定数据。
- 二级缓存:是一个有限大小的缓存,用于存储已经绑定过数据的 ViewHolder。当 ViewHolder 从 ViewHolder 缓存或 Scrap 缓存中移除后,会被放入二级缓存。如果二级缓存已满,会根据 LRU(最近最少使用)算法移除最久未使用的 ViewHolder。
- 弱引用缓存:是一个存储弱引用的缓存,用于存储那些可能长时间不会被使用的 ViewHolder。当内存紧张时,系统可能会自动回收这些弱引用所指向的 ViewHolder,以释放内存。
算法题:翻转数字,给一个 int 值,返回它的相反数,考虑正负情况和超出 int 范围抛出什么异常
以下是 Java 实现翻转数字的代码:
public class Main {
public static int reverseInt(int num) {
if (num == Integer.MIN_VALUE) {
// 整数最小值翻转会超出int范围,抛出异常
throw new IllegalArgumentException("翻转后超出int范围");
}
boolean negative = num < 0;
// 取绝对值
num = Math.abs(num);
int reversed = 0;
while (num!= 0) {
// 取出最后一位数字
int digit = num % 10;
// 判断是否超出int范围
if (reversed > (Integer.MAX_VALUE - digit) / 10) {
throw new IllegalArgumentException("翻转后超出int范围");
}
// 构建翻转后的数字
reversed = reversed * 10 + digit;
// 去掉最后一位数字
num /= 10;
}
// 如果原数是负数,翻转后也为负数
return negative? -reversed : reversed;
}
public static void main(String[] args) {
int num = 123;
try {
int reversed = reverseInt(num);
System.out.println("翻转后的数字: " + reversed);
} catch (IllegalArgumentException e) {
System.out.println("错误: " + e.getMessage());
}
}
}
在 Java 中,int类型的范围是 - 2^31 到 2^31 - 1,如果翻转后的数字超出这个范围,就会抛出IllegalArgumentException异常。
从有序数组中选出两数之和等于 target,返回索引,口述优化思路
可以使用双指针法来解决这个问题。具体思路是:
- 定义两个指针,一个指针指向数组的开头,另一个指针指向数组的结尾。
- 计算两个指针所指向元素的和,如果和等于
target,则找到了符合条件的两个数,返回它们的索引。 - 如果和小于
target,说明需要增大和,将指向开头的指针向右移动一位。 - 如果和大于
target,说明需要减小和,将指向结尾的指针向左移动一位。 - 重复上述步骤,直到找到符合条件的两个数或者两个指针相遇。
优化思路:
- 可以先对数组进行预处理,去除数组中的重复元素,这样可以减少不必要的计算。
- 如果数组非常大,可以考虑使用二分查找等更高效的算法来进一步提高查找效率。
- 在找到一组符合条件的数后,可以根据具体需求决定是否继续查找其他符合条件的数,以提高效率。
操作过数据库吗?介绍数据库索引的概念
在 Android 开发中,经常会操作数据库,如 SQLite 数据库。
数据库索引就像是一本书的目录,能帮助快速找到所需的数据。它是一种数据结构,是对数据库表中一列或多列的值进行排序的结构。比如在一个存储用户信息的表中,对 “用户名” 字段建立索引,那么当查询特定用户名的用户信息时,数据库就可以通过索引快速定位到对应的记录,而不用全表扫描。
索引的存在大大提高了数据的查询效率,特别是在数据量较大的情况下。但索引也不是越多越好,因为它会占用额外的存储空间,并且在数据插入、更新和删除时,也需要维护索引,会增加一定的开销。
介绍数据库的事务
数据库事务是数据库操作的一个基本概念,它是由一系列数据库操作组成的逻辑单元,这些操作要么全部成功执行,要么全部不执行,以保证数据库的一致性。
比如在银行转账操作中,从账户 A 扣除一定金额,同时向账户 B 增加相同金额,这两个操作必须作为一个整体来执行。如果只执行了从账户 A 扣款,而向账户 B 转账失败,就会导致数据不一致。事务就可以确保这两个操作要么都成功,要么都回滚,即撤销已经执行的操作,使数据库回到事务开始前的状态。
事务具有 ACID 特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。原子性保证事务中的操作要么全部完成,要么全部不完成;一致性确保事务执行前后,数据库的完整性约束没有被破坏;隔离性使得多个事务之间相互隔离,互不干扰;持久性表示事务一旦提交,对数据库的更改就会永久保存。
在 Android 中使用 SQLite 数据库时,可以通过 SQLiteDatabase 的 beginTransaction () 方法开始一个事务,执行一系列数据库操作后,通过 setTransactionSuccessful () 标记事务成功,最后通过 endTransaction () 结束事务,如果事务执行过程中出现异常,就不会提交事务,从而保证数据的一致性。
列举数据库索引的种类
常见的数据库索引种类有以下几种:
- B 树索引:这是最常见的索引类型,它以 B 树这种数据结构来存储索引数据。B 树的特点是每个节点可以包含多个键值和指针,能够快速地进行查找、插入和删除操作,适用于范围查询和等值查询。
- 哈希索引:哈希索引使用哈希函数将索引键值映射到一个固定大小的哈希表中,通过计算哈希值来快速定位数据。它的优点是查找速度非常快,对于等值查询效率极高,但不适合范围查询。
- 全文索引:主要用于对文本类型的数据进行索引,能够快速地进行全文搜索,比如在搜索文章、评论等内容时非常有用。它会对文本内容进行分词等处理,建立倒排索引,以便快速定位包含特定关键词的记录。
- 聚集索引:聚集索引决定了数据在表中的物理存储顺序,表中的数据会按照聚集索引列的值进行排序存储。一个表只能有一个聚集索引,它对于基于索引列的范围查询和排序操作非常高效。
- 非聚集索引:非聚集索引与聚集索引相反,它不决定数据的物理存储顺序,索引结构和数据是分开存储的。一个表可以有多个非聚集索引,它可以提高对非聚集索引列的查询效率。
说明什么时候不需要使用索引(什么时候使用索引反倒降低效率)
虽然索引通常能提高查询效率,但在某些情况下使用索引反而会降低效率,具体如下:
- 数据量小的表:如果表中的数据量很少,全表扫描的成本很低,使用索引可能会增加额外的开销,因为数据库需要维护索引结构,此时不使用索引可能效率更高。
- 更新频繁的列:如果某列的数据更新非常频繁,那么每次更新数据时,都需要同时更新对应的索引,这会增加大量的开销,可能导致整体性能下降。例如,一个记录用户操作日志的表,其中的 “操作时间” 字段几乎每次都会更新,就不适合建立索引。
- 选择性低的列:选择性是指索引列中不同值的数量与总行数的比例。如果一个列的选择性很低,比如性别列,只有 “男”“女” 两个值,建立索引的意义不大,因为通过索引查询并不能显著减少需要扫描的数据量。
- 不常使用的列:如果某些列在查询中很少被用到,为这些列建立索引只会增加存储空间和维护成本,而不会带来实际的性能提升。
- 包含大量 NULL 值的列:索引通常不存储 NULL 值,或者对 NULL 值的处理比较特殊。如果列中存在大量的 NULL 值,索引的效果可能会大打折扣,甚至可能导致查询优化器选择不使用索引。
介绍数据库优化方法
数据库优化有多种方法,以下是一些常见的:
- 合理设计表结构:遵循数据库设计的范式,减少数据冗余,提高数据的完整性和一致性。例如,将数据按照不同的主题和关系拆分成多个表,避免在一个表中存储过多无关的数据。同时,根据业务需求合理选择数据类型,对于固定长度的数据使用定长数据类型,对于可能存储大量文本的数据使用合适的大对象数据类型。
- 优化查询语句:编写高效的查询语句,避免使用不必要的子查询和嵌套查询,尽量使用连接查询来代替。对于复杂的查询,可以使用索引覆盖查询,即让查询只需要访问索引而不需要访问数据行,以提高查询速度。例如,在查询只需要获取某些列的值时,尽量只选择这些列,而不是使用 “SELECT *”。
- 使用存储过程:将复杂的业务逻辑封装在存储过程中,存储过程在数据库服务器端执行,可以减少客户端和服务器之间的数据传输量,提高执行效率。同时,存储过程可以被缓存,下次执行时可以直接调用缓存中的执行计划。
- 定期清理和维护数据库:删除不再使用的数据和索引,定期对数据库进行碎片整理,以提高数据库的性能。对于日志文件等占用空间较大且可以定期清理的文件,要及时进行清理,释放磁盘空间。
- 数据缓存:可以在应用层使用缓存机制,将经常访问的数据缓存起来,避免频繁地查询数据库。例如,使用内存缓存框架如 LruCache 等,将热点数据缓存在内存中,当需要访问数据时,先从缓存中查找,如果缓存中没有再查询数据库。
介绍数据库视图的作用
数据库视图是一种虚拟的表,它是从一个或多个实际表中导出的。其作用主要体现在以下几个方面:
- 简化数据查询:当需要从多个表中获取数据并进行复杂的关联查询时,视图可以将这些复杂的查询封装起来。比如在一个包含 “学生表”“课程表” 和 “成绩表” 的数据库中,若经常需要查询学生的姓名、课程名称以及成绩,可创建一个视图来关联这三张表,之后只需查询该视图,而不必每次都编写复杂的多表关联查询语句。
- 数据安全控制:可以通过视图来限制用户对数据的访问权限。假设数据库中有一张 “员工信息表”,包含敏感信息如工资等。对于普通员工,可创建一个只包含员工基本信息(如姓名、职位等)的视图,让他们只能访问这个视图,而无法直接获取工资等敏感数据,从而保证了数据的安全性。
- 数据独立性:当底层表的结构发生变化时,若使用了视图,只需修改视图的定义,而不必修改所有依赖该数据的应用程序。例如,在 “商品表” 中添加了一个新的字段,但某些查询只需要原来的几个字段,此时可在视图中仍然只选择原来的字段,应用程序无需修改即可继续使用视图获取数据。
- 数据整合与共享:在多个不同的数据源或不同结构的表中,如果存在相关的数据需要整合在一起展示或使用,视图可以将这些数据进行统一的整合。比如一个公司有不同部门管理的客户数据、订单数据等,通过视图可以将这些数据整合起来,方便各个部门共享和使用。
说明数据库游标和游标窗口的区别
数据库游标和游标窗口是数据库操作中的两个不同概念,它们有以下区别:
- 概念与功能
- 游标:是一种用于对查询结果集进行逐行操作的数据库对象。它允许在查询结果集中定位到特定的行,并对该行数据进行读取、更新等操作。例如,在遍历一个查询结果集来统计某些数据时,就可以使用游标逐行获取数据并进行计算。
- 游标窗口:是游标中的一个概念,它定义了一个在结果集中的子集范围,游标可以在这个窗口内进行移动和操作。游标窗口可以看作是在整个结果集上划定的一个特定区域,用于更精细地控制数据访问。
- 操作范围
- 游标:可以遍历整个查询结果集,从第一行一直移动到最后一行,对每一行数据进行处理。
- 游标窗口:只能在定义好的窗口范围内进行操作,窗口大小可以根据需要进行设置。比如设置一个大小为 10 的游标窗口,那么游标只能在这 10 行数据的范围内移动和操作。
- 应用场景
- 游标:适用于需要对结果集中的每一行进行详细处理的情况,如数据转换、复杂的业务逻辑处理等。比如将一个日期格式的数据转换为另一种格式,就可以通过游标逐行读取日期数据并进行转换。
- 游标窗口:常用于需要在一定范围内进行数据操作的场景,比如分页显示数据。通过设置游标窗口大小为每页显示的记录数,就可以方便地实现分页功能,每次只获取窗口内的数据进行显示。
介绍数据库索引策略
数据库索引策略是为了提高数据库查询效率而采取的一系列方法和规则,常见的索引策略有以下几种:
- 选择合适的列建立索引:通常选择在经常用于查询条件、连接条件以及排序的列上建立索引。例如在 “订单表” 中,“订单日期”“客户 ID” 等经常用于查询过滤的列就适合建立索引,能快速定位到符合条件的记录。
- 覆盖索引:尽量创建覆盖索引,即索引中包含了查询所需要的所有列。这样在查询时,数据库只需要从索引中获取数据,而不必再去读取数据表,大大提高了查询效率。比如查询 “员工表” 中的员工姓名和职位,若这两列都在一个索引中,就可以直接从索引中获取数据。
- 前缀索引:对于较长的字符串列,可以使用前缀索引。只取字符串的前几个字符来建立索引,既能减少索引的存储空间,又能提高查询效率。比如对于一个 “地址” 列,可能只需要取前 10 个字符作为索引,就可以满足大部分查询需求。
- 联合索引:当多个列经常一起用于查询条件时,可创建联合索引。例如在 “学生选课表” 中,经常根据 “学生 ID” 和 “课程 ID” 来查询选课记录,就可以创建一个包含这两列的联合索引,能有效提高查询速度。
- 索引维护:定期对索引进行维护,包括删除不再使用的索引,对索引进行重建或重组等。随着数据的不断插入、更新和删除,索引可能会变得碎片化,影响查询效率,通过定期维护可以保持索引的性能。
TCP 和 UDP 哪个效率高,为什么?
TCP(传输控制协议)和 UDP(用户数据报协议)是两种不同的传输层协议,一般来说 UDP 的效率相对更高,原因如下:
- 连接方式
- TCP:是面向连接的协议,在数据传输前需要先建立连接,传输完成后还要释放连接。这个过程需要进行三次握手和四次挥手等操作,会消耗一定的时间和资源。
- UDP:是无连接的协议,发送数据时不需要事先建立连接,直接将数据报发送出去即可,减少了连接建立和释放的开销,在一些对实时性要求高的场景下,能更快地传输数据。
- 数据可靠性
- TCP:提供可靠的传输服务,它通过序列号、确认应答、重传机制等保证数据的有序性和完整性。如果数据传输出现错误或丢失,TCP 会自动重传,这虽然保证了数据的准确性,但也增加了传输的时间和复杂性。
- UDP:不保证数据的可靠性,它不会对数据进行确认和重传,数据可能会出现丢失、乱序等情况。不过在一些对数据准确性要求不高,如视频直播、实时游戏等场景中,少量的数据丢失不影响整体的效果,UDP 的这种特性反而能提高传输效率。
- 头部开销
- TCP:头部长度一般为 20 字节,包含了很多用于保证可靠性和流量控制等的字段。
- UDP:头部只有 8 字节,相对简单,只包含源端口、目的端口、长度和校验和等基本信息,减少了数据传输时的额外开销,提高了数据传输的效率。
简述 HTTP 和 HTTPS 的区别
HTTP(超文本传输协议)和 HTTPS(超文本传输安全协议)有以下主要区别:
- 连接方式与安全性
- HTTP:连接相对简单,数据以明文形式传输,在数据传输过程中容易被窃取、篡改或监听,存在较大的安全风险。例如,用户通过 HTTP 访问一个网站并输入用户名和密码,这些信息可能会被黑客拦截获取。
- HTTPS:在 HTTP 的基础上加入了 SSL/TLS 加密层,对数据进行加密处理,使数据在传输过程中变成密文,只有合法的接收方才能解密和读取数据,大大提高了数据的安全性。
- 端口与证书
- HTTP:默认使用 80 端口进行数据传输。
- HTTPS:默认使用 443 端口,并且需要服务器拥有由权威证书颁发机构颁发的数字证书,客户端可以通过验证证书来确认服务器的身份是否可信。
- 性能与兼容性
- HTTP:性能相对较高,因为不需要进行加密和解密等操作,数据传输速度较快。在兼容性方面,几乎所有的浏览器和设备都支持 HTTP 协议。
- HTTPS:由于加密和解密操作会消耗一定的计算资源和时间,性能相对 HTTP 略低。不过随着硬件性能的提升,这种性能差异在大多数情况下并不明显。在兼容性方面,现代的浏览器和设备也都广泛支持 HTTPS,但在一些老旧的设备或环境中可能存在兼容性问题。
- 成本
- HTTP:搭建和维护成本较低,不需要购买数字证书等额外的费用。
- HTTPS:需要购买数字证书,证书的价格因品牌和类型而异,增加了一定的成本。同时,服务器的配置和维护也可能需要更多的资源和技术支持。
描述 TCP 的三次握手过程,解释为什么是三次握手,以及为什么是四次挥手
- 三次握手过程
- 客户端向服务器发送一个 SYN(同步)包,其中包含客户端的初始序列号(seq=x),此时客户端进入 SYN_SENT 状态,等待服务器确认。
- 服务器接收到客户端的 SYN 包后,会向客户端发送一个 SYN+ACK 包,即发送自己的初始序列号(seq=y),同时确认客户端的序列号(ack=x+1),此时服务器进入 SYN_RCVD 状态。
- 客户端收到服务器的 SYN+ACK 包后,向服务器发送一个 ACK 包,确认服务器的序列号(ack=y+1),自己的序列号为(seq=x+1),此时客户端和服务器都进入 ESTABLISHED 状态,连接建立成功。
- 为什么是三次握手:主要是为了确保双方都能确认对方的发送和接收能力。第一次握手客户端发送 SYN,服务器能知道客户端的发送能力和自己的接收能力;第二次握手服务器发送 SYN+ACK,客户端能知道服务器的发送和接收能力以及自己的接收能力;第三次握手客户端发送 ACK,服务器能知道客户端的发送能力。这样双方都确认了彼此的收发能力,保证连接可靠。
- 为什么是四次挥手:因为 TCP 连接是全双工的,双方都可以独立地发送和接收数据。当一方想要关闭连接时,比如客户端发送 FIN 包表示自己没有数据要发送了,但此时服务器可能还有数据要发送,所以服务器先发送 ACK 确认收到客户端的 FIN,等服务器数据发送完后,再发送 FIN 包给客户端,客户端收到后发送 ACK 确认,这样就完成了四次挥手。
解释 TCP 为什么要三次连接,以及滑动窗口的概念
- TCP 三次连接的原因:本质上和三次握手的目的一致,是为了实现可靠的连接建立。通过三次交互,客户端和服务器能够同步各自的序列号,确认双方的发送和接收能力,避免资源的浪费和数据传输的混乱,确保连接是可靠的,为后续的数据传输奠定基础。
- 滑动窗口的概念:滑动窗口是 TCP 协议用于实现流量控制和提高传输效率的一种机制。它允许发送方在收到接收方的确认之前,连续发送多个数据段。窗口的大小表示发送方可以在未收到确认的情况下发送的数据量。接收方通过在确认报文中通告自己的窗口大小,告诉发送方自己能够接收的数据量。发送方根据接收方通告的窗口大小和自身的拥塞窗口大小来决定发送数据的量。随着数据的发送和确认,窗口会在数据序列上滑动,所以叫滑动窗口。比如,发送方发送了一段数据,接收方成功接收并确认后,窗口就会向前滑动,允许发送方发送更多的数据,这样可以提高数据传输的效率,同时避免发送方发送过快导致接收方处理不过来。
描述 TCP 连接建立的过程,以及拥塞控制原理
- TCP 连接建立的过程:就是前面提到的三次握手过程。客户端先发送 SYN 包发起连接请求,服务器收到后发送 SYN+ACK 包进行响应,客户端再发送 ACK 包完成连接建立,双方进入可进行数据传输的状态。
- 拥塞控制原理:TCP 的拥塞控制主要是为了防止网络出现过载而导致性能下降。它通过四个算法来实现,分别是慢开始、拥塞避免、快重传和快恢复。慢开始阶段,发送方从一个较小的拥塞窗口开始,每次收到一个确认就将拥塞窗口大小加倍,快速增加发送数据的能力。当拥塞窗口达到一个阈值时,进入拥塞避免阶段,此时拥塞窗口不再加倍增长,而是每经过一个往返时间(RTT)就增加一个 MSS(最大报文段长度),避免网络拥塞。如果发送方发现丢包,就认为出现了拥塞,会执行快重传和快恢复算法。快重传要求接收方在收到失序的报文段时,立即发送对已收到的最高序号报文段的重复确认,发送方收到三个重复确认时,就认为后面的数据丢失了,立即重传丢失的数据段。快恢复则是在快重传后,将拥塞窗口大小减半,然后进入拥塞避免阶段,继续调整发送数据的速率,以适应网络状况。
说明 TCP、UDP 的端口号位于哪一层?如果 TCP 和 UDP 访问同一个端口号会怎样
- TCP、UDP 的端口号所在层:TCP 和 UDP 的端口号都位于传输层。传输层的主要功能是为应用层提供端到端的通信服务,端口号就是用来标识不同的应用程序或进程,使得传输层能够将数据准确地交付到正确的应用程序或进程。
- TCP 和 UDP 访问同一个端口号的情况:在大多数操作系统中,TCP 和 UDP 可以同时使用同一个端口号,因为它们是不同的传输协议,有各自独立的端口号空间和连接状态。比如,一个服务器可以在同一个端口号上同时监听 TCP 和 UDP 的连接请求,对于 TCP 连接,服务器按照 TCP 的协议规则进行处理;对于 UDP 数据包,服务器按照 UDP 的协议规则进行处理。但是,对于同一个应用程序或进程来说,通常不会同时在同一个端口号上使用 TCP 和 UDP 进行通信,因为这可能会导致数据处理的混乱。不过在某些特殊场景下,比如同时需要可靠的 TCP 连接和实时性要求高的 UDP 通信时,也可能会出现这种情况,但需要开发者仔细处理和区分不同协议的数据。
阐述 TCP 与 UDP 的区别,分别有哪些应用场景
- TCP 与 UDP 的区别
- 连接性:TCP 是面向连接的协议,在数据传输前需要先建立连接,数据传输完成后要关闭连接;UDP 是无连接的协议,发送数据前不需要建立连接,直接将数据报发送出去。
- 可靠性:TCP 提供可靠的传输服务,通过序列号、确认应答、重传机制等保证数据的准确无误和有序到达;UDP 不保证数据的可靠传输,可能会出现数据丢失、重复或乱序的情况。
- 传输效率:TCP 由于需要进行连接建立、确认等操作,传输效率相对较低;UDP 不需要这些操作,传输效率较高,延迟较小。
- 数据量:TCP 适合传输大量数据,因为它能够保证数据的完整性;UDP 适合传输少量数据,比如一些实时性要求高但对数据准确性要求不是特别严格的数据。
- 拥塞控制:TCP 有复杂的拥塞控制机制,能根据网络状况调整发送速率;UDP 没有拥塞控制,发送端不会根据网络状况调整发送速率。
- 应用场景
- TCP 的应用场景:常用于对数据准确性和完整性要求高的场景,如文件传输、电子邮件、网页浏览等。在文件传输中,确保文件完整无误地传输是至关重要的,TCP 的可靠传输特性能够满足这一需求。
- UDP 的应用场景:常用于对实时性要求高、对数据准确性要求相对较低的场景,如视频直播、实时游戏、语音通话等。在视频直播中,偶尔丢失一些数据帧可能对观看体验影响不大,但如果因为等待重传数据而导致延迟增加,会严重影响用户体验,所以 UDP 更适合这种场景。
有接触过基于 TCP/UDP 的编程吗?
在 Android 开发以及更广泛的网络编程领域,基于 TCP 和 UDP 的编程颇为常见。
TCP(传输控制协议)提供可靠的、面向连接的数据传输服务。在 Android 开发中,使用 Socket 进行网络通信时,常基于 TCP 协议实现。例如,开发一个即时通讯应用,就需要确保消息准确无误且按顺序送达对方,TCP 就能满足这样的需求。以下是简单的基于 TCP 的客户端代码示例:
try {
Socket socket = new Socket("服务器IP", 端口号);
OutputStream outputStream = socket.getOutputStream();
PrintWriter out = new PrintWriter(outputStream, true);
out.println("Hello, Server!");
InputStream inputStream = socket.getInputStream();
BufferedReader in = new BufferedReader(new InputStreamReader(inputStream));
String response = in.readLine();
System.out.println("来自服务器的响应: " + response);
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
这段代码创建了一个 TCP 套接字连接到指定服务器,发送一条消息并接收服务器的响应。
UDP(用户数据报协议)则提供无连接、不可靠的数据传输服务,但具有传输速度快、延迟低的特点。在开发实时性要求高但对数据准确性要求相对较低的应用,如在线游戏或实时视频流应用时,UDP 可能更为合适。例如开发一个简单的多人在线游戏,偶尔丢失一些位置更新数据对游戏体验影响不大,但需要保证数据快速传输,UDP 就可满足此需求。基于 UDP 的客户端代码示例如下:
try {
DatagramSocket socket = new DatagramSocket();
InetAddress serverAddress = InetAddress.getByName("服务器IP");
String message = "Hello, Server!";
byte[] sendData = message.getBytes();
DatagramPacket sendPacket = new DatagramPacket(sendData, sendData.length, serverAddress, 端口号);
socket.send(sendPacket);
byte[] receiveData = new byte[1024];
DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length);
socket.receive(receivePacket);
String response = new String(receivePacket.getData(), 0, receivePacket.getLength());
System.out.println("来自服务器的响应: " + response);
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
这段代码通过 UDP 套接字向服务器发送消息并接收响应。
HTTP 哪些操作是幂等的?
在 HTTP 协议中,幂等操作指的是多次执行相同的操作所产生的效果与一次执行该操作的效果相同。主要的幂等操作有:
- GET:用于获取资源,无论执行多少次,都只是获取服务器上已有的资源,不会对资源本身造成任何改变。例如,多次请求
https://example.com/api/article/1获取特定文章内容,每次得到的结果都是相同的文章数据,不会对文章本身有任何修改。 - HEAD:与 GET 类似,只不过它只获取资源的头部信息,不返回资源主体内容。同样,多次执行 HEAD 请求不会对资源产生额外影响,是幂等的。
- PUT:用于更新资源,将请求中的数据替换指定资源。如果多次对同一资源执行相同的 PUT 操作,只要请求数据不变,资源的最终状态是一样的。例如,多次向
https://example.com/api/user/1发送包含相同用户信息的 PUT 请求,最终用户信息不会因为多次请求而改变。 - DELETE:用于删除资源,多次对同一个资源执行 DELETE 操作,只要第一次删除成功,后续再执行也只是返回资源已不存在的响应,不会产生额外影响。例如,多次请求
https://example.com/api/file/1删除特定文件,第一次删除后,后续请求都只是确认文件已被删除。
与之相对,POST 操作通常不是幂等的。POST 一般用于向服务器提交新的数据,每次执行 POST 操作可能会创建新的资源实例。例如,多次提交一个创建新用户的 POST 请求,会在服务器上创建多个不同的用户。
简述 HTTPS 的对称非对称加密原理、具体操作及数据传输时使用对称加密的原因。
HTTPS 综合运用了对称加密和非对称加密,以确保数据传输的安全性。
- 对称加密原理:对称加密使用相同的密钥进行加密和解密。发送方使用密钥对数据进行加密,接收方使用同一密钥对加密后的数据进行解密。例如,在一个简单的聊天应用中,客户端和服务器预先约定好一个密钥,客户端发送消息时用该密钥加密,服务器收到后用相同密钥解密,这样即使数据在传输过程中被截获,没有密钥也无法获取真实内容。
- 非对称加密原理:非对称加密使用一对密钥,即公钥和私钥。公钥可以公开,任何人都能用公钥加密数据,但只有持有私钥的人才能解密。比如,服务器生成一对密钥,将公钥发布出去,客户端使用公钥加密数据发送给服务器,只有服务器用自己的私钥才能解密。
- 具体操作:在 HTTPS 连接建立时,首先服务器将自己的公钥发送给客户端,客户端生成一个随机的对称密钥,用服务器的公钥加密这个对称密钥后发送给服务器,服务器用私钥解密得到对称密钥。之后,客户端和服务器之间的数据传输就使用这个对称密钥进行对称加密和解密。
- 数据传输时使用对称加密的原因:对称加密的加密和解密速度快,适合大量数据的加密传输。相比之下,非对称加密虽然安全性高,但加密和解密速度较慢,若全程使用非对称加密,会严重影响数据传输效率。因此在 HTTPS 中,利用非对称加密的安全性来交换对称密钥,然后使用对称加密来处理大量数据的传输,兼顾了安全与效率。
说说 HTTPS 的原理,对称加密和非对称加密的概念,证书的作用,HTTP1.0 和 HTTP1.1 的区别。
- HTTPS 的原理:HTTPS 在 HTTP 的基础上加入了 SSL/TLS 协议,用于对数据进行加密传输。在连接建立阶段,客户端向服务器发送请求,服务器返回包含公钥的数字证书。客户端验证证书合法性后,生成一个随机的对称密钥,用服务器公钥加密后发送给服务器。服务器用私钥解密得到对称密钥,之后双方使用这个对称密钥对传输的数据进行加密和解密。
- 对称加密概念:对称加密使用同一个密钥进行加密和解密操作。这种方式加密和解密速度快,适用于大量数据的加密,但密钥的管理和分发存在风险,因为通信双方需要共享密钥,若密钥泄露,数据就会被破解。
- 非对称加密概念:非对称加密使用一对密钥,即公钥和私钥。公钥可以公开,用于加密数据;私钥由持有者妥善保管,用于解密数据。用公钥加密的数据只能用对应的私钥解密,反之亦然。非对称加密解决了密钥分发的问题,但加密和解密速度相对较慢。
- 证书的作用:数字证书由权威的证书颁发机构(CA)颁发,包含了服务器的公钥、服务器信息以及 CA 的签名等内容。客户端通过验证证书的合法性,确保连接的服务器是真实可靠的,防止中间人攻击。如果证书被篡改,客户端验证签名时会发现异常,从而中断连接。
- HTTP1.0 和 HTTP1.1 的区别
- 连接方式:HTTP1.0 默认使用短连接,每次请求都需要建立新的 TCP 连接,请求完成后连接关闭。这在频繁请求时会增加连接建立的开销。而 HTTP1.1 默认使用长连接,一次 TCP 连接可以进行多次请求和响应,减少了连接建立的次数,提高了效率。
- 缓存处理:HTTP1.1 引入了更多的缓存控制机制,如
Cache-Control头字段,提供了更细粒度的缓存控制,能更好地控制资源的缓存和过期策略。相比之下,HTTP1.0 的缓存机制相对简单。 - 带宽优化:HTTP1.1 支持断点续传,当客户端下载一个大文件时,如果网络中断,在 HTTP1.1 下可以从断点处继续下载,而 HTTP1.0 则需要重新开始下载。此外,HTTP1.1 还支持管线化,允许客户端在一个 TCP 连接上连续发送多个请求,而不需要等待前一个请求的响应,进一步提高了带宽利用率。
描述从 url 到显示网页的过程。
当在浏览器中输入一个 URL 并回车后,大致会经历以下几个过程:
- DNS 解析:浏览器首先检查本地 DNS 缓存,看是否有该 URL 对应的 IP 地址。如果没有,就会向本地 DNS 服务器发送查询请求。本地 DNS 服务器若没有缓存,会向根 DNS 服务器查询,根 DNS 服务器会返回顶级域名服务器的地址,本地 DNS 服务器再向顶级域名服务器查询,得到权威 DNS 服务器的地址,最后从权威 DNS 服务器获取到该 URL 对应的 IP 地址。例如,输入
www.example.com,通过 DNS 解析找到其对应的 IP 地址192.168.1.1。 - TCP 连接建立:浏览器拿到 IP 地址后,会与服务器建立 TCP 连接,这就是著名的三次握手过程。浏览器发送一个 SYN 包,服务器收到后返回一个 SYN + ACK 包,浏览器再发送一个 ACK 包,至此 TCP 连接建立成功。
- 发送 HTTP 请求:TCP 连接建立后,浏览器根据输入的 URL 生成 HTTP 请求报文,包含请求方法(如 GET、POST 等)、请求头(包含浏览器信息、缓存控制等)以及请求体(如果是 POST 请求)。例如,若输入的是一个普通网页 URL,浏览器可能发送一个 GET 请求,请求获取网页资源。
- 服务器处理请求:服务器接收到 HTTP 请求后,根据请求的内容进行处理。如果是静态资源请求,服务器直接从文件系统中读取资源;如果是动态请求,服务器会调用相应的应用程序逻辑(如 PHP、Java Servlet 等),处理业务逻辑并生成响应内容。
- 返回 HTTP 响应:服务器将处理后的结果封装成 HTTP 响应报文,包括响应状态码(如 200 表示成功,404 表示未找到等)、响应头(包含内容类型、缓存控制等)以及响应体(网页内容、图片、脚本等),然后通过 TCP 连接返回给浏览器。
- 浏览器渲染页面:浏览器接收到 HTTP 响应后,首先检查响应状态码。如果是 200,开始解析响应头和响应体。根据响应头中的内容类型,浏览器知道如何处理响应体中的数据。例如,如果是 HTML 页面,浏览器会解析 HTML 代码,构建 DOM 树,同时根据 CSS 样式表对页面进行样式渲染。在解析过程中,如果遇到外部资源(如图片、脚本等),浏览器会再次发起请求获取这些资源并进行加载和渲染,最终将完整的网页呈现给用户。
说明 DNS 解析过程
DNS(Domain Name System,域名系统)解析是将人类可读的域名转换为计算机可识别的 IP 地址的过程,它对于互联网的正常运行至关重要。以下是 DNS 解析的详细过程:
- 浏览器缓存查询:当用户在浏览器中输入一个域名,浏览器首先会在自己的缓存中查找该域名对应的 IP 地址。浏览器会维护一个近期访问过的域名及其 IP 地址的缓存列表,如果能在缓存中找到匹配项,就直接使用该 IP 地址进行后续的网络请求,从而加快访问速度。例如,用户经常访问
www.example.com,浏览器缓存中可能就保存了其对应的 IP 地址,下次访问时可快速获取。 - 操作系统缓存查询:若浏览器缓存中未找到相应的 IP 地址,浏览器会向操作系统发起查询请求。操作系统也有自己的 DNS 缓存,会尝试在其中查找该域名对应的 IP 地址。操作系统的缓存通常保存了一些常用域名的解析结果,这一步也有助于提高解析效率。
- 本地 DNS 服务器查询:如果操作系统缓存中也没有找到,请求会被发送到本地 DNS 服务器。本地 DNS 服务器一般由用户的网络服务提供商(ISP)提供,它会查询自己的缓存。如果本地 DNS 服务器缓存中有该域名的解析记录,就直接返回 IP 地址给浏览器。
- 递归查询(若本地 DNS 服务器无缓存):若本地 DNS 服务器没有缓存该域名的解析记录,它会发起递归查询。本地 DNS 服务器首先向根 DNS 服务器发送查询请求,根 DNS 服务器并不直接提供域名到 IP 地址的映射,而是返回顶级域名服务器(TLD Server)的地址。例如,对于
.com域名,根 DNS 服务器会告知本地 DNS 服务器.com顶级域名服务器的位置。 - 迭代查询:本地 DNS 服务器接着向顶级域名服务器发送查询请求。顶级域名服务器会返回权威 DNS 服务器的地址,权威 DNS 服务器是负责特定域名解析的服务器,通常由域名所有者设置。
- 获取 IP 地址:本地 DNS 服务器最后向权威 DNS 服务器查询,权威 DNS 服务器会返回该域名对应的 IP 地址。本地 DNS 服务器收到 IP 地址后,一方面会将其返回给浏览器,另一方面会将该解析记录缓存起来,以便下次更快地响应相同域名的查询。
比较 http 1.0、http 1.1 和 http 2.0 的区别
HTTP 协议在不断发展,HTTP 1.0、HTTP 1.1 和 HTTP 2.0 之间存在诸多重要区别:
- 连接方式
- HTTP 1.0:默认使用短连接,即每次 HTTP 请求都需要建立一个新的 TCP 连接,请求完成后连接立即关闭。这在频繁请求资源时,会导致大量的 TCP 连接建立和关闭开销,增加了网络延迟。例如,一个网页包含多个图片、脚本等资源,每个资源请求都要重新建立连接,效率较低。
- HTTP 1.1:默认采用长连接(Persistent Connection),通过
Connection: keep - alive字段来实现。一次 TCP 连接可以进行多次 HTTP 请求和响应,减少了连接建立的次数,提高了资源加载效率。但同一时间内,一个 TCP 连接只能处理一个请求,下一个请求需要等待前一个请求完成。 - HTTP 2.0:采用多路复用(Multiplexing)技术,允许在同一个 TCP 连接上同时发送多个请求和响应,并且可以交错进行,互不干扰。这极大地提高了连接的利用率,减少了延迟,尤其在请求多个资源时表现出色。
- 头部压缩
- HTTP 1.0和HTTP 1.1:头部信息通常以明文形式传输,并且每次请求都需要重复发送一些固定的头部字段,导致数据冗余,增加了传输的数据量。
- HTTP 2.0:使用 HPACK 算法对头部进行压缩,它会对头部字段进行编码和压缩,减少头部大小,从而提高传输效率。
- 资源优先级
- HTTP 1.0和HTTP 1.1:没有明确的资源优先级概念,资源按照请求的先后顺序进行加载。
- HTTP 2.0:支持为不同的资源分配优先级,服务器可以根据优先级来优先处理重要资源的请求,确保关键资源(如网页的核心样式和脚本)能够更快地加载,提升用户体验。
- 服务器推送
- HTTP 1.0和HTTP 1.1:服务器只能被动地响应客户端的请求,无法主动向客户端推送资源。
- HTTP 2.0:支持服务器推送(Server Push)功能,服务器可以根据客户端的请求,提前推送一些可能需要的资源,例如在客户端请求 HTML 页面时,服务器可以同时推送相关的 CSS 和 JavaScript 文件,减少客户端再次请求的延迟。
说明多路复用的使用场景
多路复用技术在现代网络通信中有着广泛的应用场景,主要体现在以下几个方面:
- 网页资源加载:如今的网页通常包含大量的资源,如 HTML、CSS、JavaScript 文件、图片、视频等。在 HTTP 2.0 中采用多路复用技术,浏览器可以在同一个 TCP 连接上同时向服务器请求这些不同类型的资源,服务器也能交错地将这些资源的响应发送回浏览器。这样可以避免因单个资源请求占用连接而导致其他资源等待的情况,大大提高了网页的加载速度。例如,当用户访问一个新闻网站时,页面中的文章内容、配图、广告等资源可以同时通过多路复用的 TCP 连接进行加载,快速呈现完整的页面。
- 实时通信应用:像即时通讯软件、在线游戏等实时通信应用,需要频繁地在客户端和服务器之间传输数据。多路复用技术使得这些应用可以在一个连接上同时发送和接收多种类型的消息,如文本消息、语音数据、视频数据等。例如,在多人在线游戏中,玩家的操作指令、角色位置更新、聊天消息等可以通过多路复用的连接高效传输,确保游戏的流畅性和实时性。
- 云计算和大数据传输:在云计算环境中,用户可能需要从云服务器获取大量的数据,如虚拟机镜像、存储文件等。多路复用允许在一个连接上同时传输多个数据块,提高数据传输的效率。在大数据领域,数据的采集、传输和处理过程中,多路复用技术可以确保不同类型的数据(如日志数据、传感器数据等)在一个连接上高效传输,减少连接建立和管理的开销。
- 物联网(IoT)设备通信:物联网设备通常资源有限,并且需要与服务器进行频繁的数据交互。多路复用技术使得多个物联网设备可以通过一个网络连接与服务器进行通信,每个设备的数据可以在这个连接上多路复用。例如,智能家居系统中的多个传感器(温度传感器、湿度传感器、门窗传感器等)可以通过多路复用的方式将数据发送到控制中心,提高了通信效率,同时降低了设备的功耗和网络资源的占用。
比较 get 和 post 的区别
GET 和 POST 是 HTTP 协议中两种常用的请求方法,它们在多个方面存在区别:
- 请求参数位置
- GET:请求参数附加在 URL 后面,以
?分隔,参数之间用&连接。例如,http://example.com/search?keyword=android&page=1。这种方式使得参数在 URL 中可见,因此不太适合传输敏感信息,如密码等。 - POST:请求参数放在请求体中,不会显示在 URL 里。这使得 POST 方法更适合传输敏感信息或大量数据,因为 URL 的长度有限制,而请求体的大小限制相对宽松。
- GET:请求参数附加在 URL 后面,以
- 安全性
- GET:由于参数暴露在 URL 中,容易被截取和篡改,安全性较低。例如,在公共网络环境下,他人可能通过抓包工具获取 URL 中的参数信息。
- POST:参数在请求体中,相对不容易被截取和篡改,安全性较高。但需要注意的是,这并不意味着 POST 绝对安全,在未加密的网络连接中,请求体的数据仍可能被窃取。
- 幂等性
- GET:具有幂等性,即多次执行相同的 GET 请求,对服务器资源的影响是相同的,不会产生额外的副作用。例如,多次请求获取一篇文章的内容,每次得到的结果应该是一样的。
- POST:通常不具有幂等性,因为 POST 一般用于创建新的资源,多次执行相同的 POST 请求可能会创建多个相同的资源。例如,多次提交创建新用户的 POST 请求,可能会在服务器上创建多个相同的用户记录。
- 缓存
- GET:可以被浏览器缓存,因为 GET 请求通常用于获取资源,相同的请求返回的结果一般是相同的,所以浏览器可以缓存 GET 请求的结果,下次请求相同 URL 时可以直接从缓存中获取,提高访问速度。
- POST:一般不会被浏览器缓存,因为 POST 请求可能会对服务器资源进行修改,缓存 POST 请求的结果可能导致数据不一致。
- 数据长度限制
- GET:由于 URL 长度有限制(不同浏览器和服务器对 URL 长度限制不同,一般在 2000 个字符左右),所以 GET 请求能传输的数据量有限。
- POST:理论上对数据长度没有限制,主要受服务器的配置和内存限制。因此,适合传输大量数据,如文件上传、表单提交等场景。
说明 DNS 的作用,列举 HTTP 的头部有哪些
- DNS 的作用:DNS(域名系统)就像是互联网的地址簿,它的主要作用是将人类易于记忆的域名(如
www.example.com)转换为计算机网络能够识别和使用的 IP 地址(如192.168.1.1)。在互联网中,计算机之间通过 IP 地址进行通信,但 IP 地址是一串数字,对于用户来说很难记忆。而域名则具有一定的语义,方便用户输入和记忆。DNS 系统通过分布式的数据库和解析机制,实现了域名到 IP 地址的快速准确转换,使得用户能够方便地访问各种网络资源。如果没有 DNS,用户就需要记住每个网站的 IP 地址才能访问,这显然是不现实的。 - HTTP 的头部列举
- 通用头部
- Cache - Control:用于控制缓存行为,如
no - cache表示不使用缓存,max - age指定缓存的有效时间。 - Connection:控制当前连接的行为,如
keep - alive表示保持连接,close表示请求完成后关闭连接。
- Cache - Control:用于控制缓存行为,如
- 请求头部
- Accept:告诉服务器客户端能够接收的媒体类型,如
text/html表示接受 HTML 格式,application/json表示接受 JSON 格式。 - Accept - Language:指定客户端偏好的语言,如
zh - CN表示中文(中国)。 - User - Agent:包含客户端的信息,如浏览器类型、版本、操作系统等,例如
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36。 - Referer:标识请求是从哪个页面发起的,有助于服务器统计来源和防止盗链。
- Accept:告诉服务器客户端能够接收的媒体类型,如
- 响应头部
- Content - Type:指示响应体的媒体类型,如
text/html; charset=UTF - 8表示 HTML 格式且字符编码为 UTF - 8。 - Content - Length:表示响应体的长度,单位为字节,服务器可以根据这个值来确定何时接收完所有数据。
- Set - Cookie:用于设置 Cookie,服务器通过这个头部将 Cookie 发送给客户端,客户端下次请求时会带上这些 Cookie。
- Content - Type:指示响应体的媒体类型,如
- 实体头部
- Last - Modified:表示资源的最后修改时间,用于缓存控制和条件请求。
- ETag:是资源的唯一标识符,类似于版本号,用于缓存控制和资源更新检测。
- 通用头部
说明对称加密算法和非对称加密算法的应用
- 对称加密算法的应用
- 数据加密存储:在本地存储敏感数据时,如用户的登录密码、支付信息等,常采用对称加密算法。像 AES 算法就被广泛用于加密手机中的用户数据,将明文数据加密成密文存储,当需要使用时再用密钥解密,这样即使设备被窃取,没有密钥也难以获取敏感信息。
- 网络通信加密:在一些对性能要求较高、通信双方密钥管理相对简单的场景中,对称加密用于加密网络传输数据。例如,在某些物联网设备之间的通信,由于设备资源有限,对称加密的高效性可保证数据在传输过程中的保密性,防止数据被窃取或篡改。
- 数据库加密:对数据库中的敏感字段进行加密,如信用卡号、身份证号等。使用对称加密算法可以确保数据在数据库存储和查询过程中的安全性,只有通过正确的密钥才能获取真实数据。
- 非对称加密算法的应用
- 数字签名:用于验证数据的来源和完整性,确保数据在传输过程中未被篡改。例如,软件开发者对软件安装包进行数字签名,用户在下载安装包后,可以通过验证数字签名来确认软件是否来自可信的开发者,且未被恶意篡改。
- 密钥交换:在网络通信中,用于安全地交换对称加密算法所需的密钥。比如在 HTTPS 通信中,客户端和服务器通过非对称加密算法协商出一个对称密钥,之后的通信就使用这个对称密钥进行加密,利用了非对称加密的安全性和对称加密的高效性。
- 身份认证:在一些安全要求较高的系统登录场景中,用户可以使用自己的私钥对某些信息进行签名,服务器通过用户的公钥来验证签名,从而确认用户的身份是否合法,实现强身份认证。
说明 https 的工作过程,HTTPS 能抓包吗,既然能抓包它为什么还安全?
- HTTPS 的工作过程
- 客户端向服务器发送请求,请求中包含客户端支持的加密算法和哈希算法等信息。
- 服务器收到请求后,从客户端提供的算法中选择一组加密算法和哈希算法,并将自己的数字证书发送给客户端,证书包含服务器的公钥等信息。
- 客户端收到服务器的证书后,首先验证证书的合法性,如证书是否由可信的证书颁发机构颁发、是否在有效期内等。如果证书合法,客户端从中提取出服务器的公钥。
- 客户端生成一个随机的对称密钥,用服务器的公钥对其加密,然后将加密后的对称密钥发送给服务器。
- 服务器用自己的私钥解密得到对称密钥,之后客户端和服务器之间的通信就使用这个对称密钥进行加密和解密,同时使用哈希算法对数据进行完整性校验。
- HTTPS 能否抓包:HTTPS 是可以抓包的。在某些特定情况下,如在用户信任了特定的根证书的情况下,通过安装特殊的抓包工具,是可以获取 HTTPS 通信中的数据的。
- HTTPS 抓包后仍安全的原因:虽然 HTTPS 可以被抓包,但它仍然是安全的。因为即使攻击者能够抓取到数据,但在没有正确密钥的情况下,抓取到的只是加密后的密文,难以从中获取有价值的信息。而且 HTTPS 通过数字证书验证服务器身份,防止用户连接到假冒的服务器,避免了中间人攻击的风险。同时,数据在传输过程中通过加密和完整性校验,保证了数据不会被篡改和泄露,只有合法的客户端和服务器才能正确地解密和处理数据。
介绍 HTTP 和 HTTPS 的区别,RSA 是什么,数字签名是什么?
- HTTP 和 HTTPS 的区别
- 安全性:HTTP 数据以明文形式传输,容易被窃取、篡改和监听;HTTPS 对数据进行加密处理,通过 SSL/TLS 协议保证数据的保密性和完整性,防止数据被非法获取和篡改。
- 连接方式:HTTP 连接相对简单,客户端向服务器发送请求,服务器响应请求后连接结束;HTTPS 在连接时需要先进行 SSL/TLS 握手,验证服务器身份和协商加密算法等参数,然后再进行数据传输。
- 端口:HTTP 默认使用 80 端口,HTTPS 默认使用 443 端口。
- 证书:HTTPS 需要服务器拥有由权威证书颁发机构颁发的数字证书,客户端可以通过验证证书来确认服务器的身份是否可信,HTTP 则不需要。
- RSA:RSA 是一种非对称加密算法,由罗纳德・李维斯特、阿迪・萨莫尔和伦纳德・阿德曼三人发明。它基于大整数分解的数学难题,其安全性依赖于将两个大素数相乘容易,但分解它们的乘积却非常困难。RSA 算法用于加密、解密、数字签名和密钥交换等场景,在网络安全领域应用广泛,比如在 HTTPS 通信中的密钥交换和数字证书中的签名等。
- 数字签名:数字签名是一种用于验证数据来源和完整性的技术。它使用非对称加密算法,发送方用自己的私钥对数据的哈希值进行加密,生成数字签名,与数据一起发送给接收方。接收方收到数据后,用发送方的公钥对数字签名进行解密,得到哈希值,再对收到的数据进行哈希运算,将得到的哈希值与解密得到的哈希值进行对比,如果一致,则说明数据未被篡改且来自合法的发送方,从而保证了数据的真实性和完整性。
分析 TCP 怎么保证可靠传输,具体展开说一下怎么保证,如果包中途丢了怎么办,假如丢包,发送方会主动发送补偿吗,包中途堵塞触发了超时重传,接收方收到两个包会怎么样,怎么实现拥塞控制,怎么发现拥塞,如何让 udp 像 tcp 一样可靠?
- TCP 保证可靠传输的方式
- 序列号和确认号:TCP 为每个发送的字节都分配一个序列号,接收方通过确认号告知发送方哪些数据已经成功接收,发送方根据确认号来判断数据是否被正确接收。
- 校验和:TCP 对发送的数据进行校验和计算,接收方收到数据后重新计算校验和并与发送方的校验和进行对比,若不一致则说明数据在传输过程中出现了错误,要求发送方重传。
- 重传机制:发送方在发送数据后会启动一个定时器,如果在规定时间内没有收到接收方的确认,就会重新发送数据。
- 包中途丢了的处理:当包中途丢失时,发送方在定时器超时后会重新发送丢失的包。如果接收方没有收到某个包的前序包,即使收到后续包也会缓存起来,等待前序包到达后再按顺序提交给应用层。
- 发送方主动补偿:是的,发送方会主动补偿。当定时器超时或者收到接收方的重复确认(暗示有包丢失)时,发送方会主动重发丢失的包。
- 超时重传后接收方收到两个包:接收方会根据序列号判断包的顺序和重复性,对于重复的包会直接丢弃,只将正确顺序的包提交给应用层。
- 拥塞控制实现:TCP 通过慢开始、拥塞避免、快重传和快恢复等算法来实现拥塞控制。慢开始阶段,发送方以指数增长的方式增加发送窗口大小;拥塞避免阶段,发送窗口以线性方式增长;当出现拥塞时,通过快重传和快恢复算法调整发送窗口大小,减少数据发送量。
- 拥塞发现:当发送方收到三个重复的确认或者定时器超时时,就认为网络出现了拥塞。
- UDP 实现可靠传输:可以通过在应用层实现类似 TCP 的机制,如序列号、确认号、重传机制、拥塞控制等。也可以使用一些可靠的 UDP 框架,如 RUDP 等,这些框架在 UDP 的基础上增加了可靠性保证的功能,来实现类似 TCP 的可靠传输。
把地址贴到浏览器按回车后的完整过程,http 是 tcp 还是 udp 呢?
- 把地址贴到浏览器按回车后的完整过程
- DNS 解析:浏览器首先检查本地的 DNS 缓存,如果没有找到对应的 IP 地址,就会向本地 DNS 服务器发送请求,本地 DNS 服务器再通过递归或迭代查询等方式,从根域名服务器开始,逐步查找目标域名对应的 IP 地址,最终将 IP 地址返回给浏览器。
- 建立连接:浏览器与目标服务器的 IP 地址建立 TCP 连接,通过 TCP 的三次握手过程,确保客户端和服务器之间的连接是可靠的。
- 发送请求:连接建立后,浏览器根据用户输入的地址和操作,按照 HTTP 协议的格式构造请求报文,包含请求方法、请求头、请求体等信息,然后将请求报文发送给服务器。
- 服务器处理:服务器收到请求后,根据请求的内容进行处理,如查询数据库、执行脚本等,生成相应的响应数据。
- 发送响应:服务器将响应数据按照 HTTP 协议的格式封装成响应报文,包含响应状态码、响应头、响应体等信息,发送给浏览器。
- 浏览器渲染:浏览器收到响应报文后,解析响应内容,根据 HTML、CSS 和 JavaScript 等文件,渲染出网页的页面,展示给用户。
- HTTP 是基于 TCP 还是 UDP:HTTP 是基于 TCP 协议的。因为 TCP 提供了可靠的、面向连接的字节流服务,能够保证数据的有序传输和完整性,符合 HTTP 对数据传输可靠性的要求,确保网页的请求和响应能够准确无误地进行。而 UDP 是无连接的、不可靠的传输协议,不适合 HTTP 这种需要保证数据准确性和完整性的应用场景。
如何优化 DNS? DNS 优化可以从多个方面入手。首先是缓存优化,浏览器会缓存 DNS 记录,以减少后续访问相同域名时的解析时间。操作系统也有自己的 DNS 缓存,合理配置缓存时间可以提高效率。比如在 Linux 系统中,可以通过修改/etc/resolv.conf文件中的options参数来调整缓存策略。同时,使用内容分发网络(CDN)可以将内容缓存到离用户更近的节点,CDN 会根据用户的地理位置等因素,智能地选择最优的服务器来提供内容,从而减少 DNS 解析的距离和时间。
其次是 DNS 服务器的选择,选择可靠、性能好的 DNS 服务器至关重要。像谷歌的公共 DNS(8.8.8.8 和 8.8.4.4),具有较快的解析速度和较高的稳定性。企业也可以搭建自己的私有 DNS 服务器,对内部域名进行快速解析,提高访问效率。还可以采用 DNS 负载均衡技术,将用户的请求分配到多个服务器上,减轻单个服务器的压力,提高整体的响应速度和可用性。另外,优化 DNS 查询的网络环境,减少网络延迟和丢包,也能提升 DNS 解析的性能。
介绍 SSL/TLS,以及位于哪一层? SSL(安全套接层)和 TLS(传输层安全)是为网络通信提供安全及数据完整性的一种安全协议。TLS 是 SSL 的后继版本,现在通常所说的 SSL/TLS 主要指 TLS。
SSL/TLS 的主要功能包括加密数据传输,确保数据在传输过程中不被窃取或篡改;身份验证,通过数字证书验证服务器和客户端的身份,防止中间人攻击;数据完整性保护,保证数据在传输过程中没有被意外或恶意地修改。
在网络协议栈中,SSL/TLS 位于传输层和应用层之间。它对传输层的数据进行加密和封装,然后将加密后的数据交给应用层,应用层的数据也会先经过 SSL/TLS 的处理再交给传输层进行传输。以 HTTPS 为例,HTTP 协议的数据在经过 SSL/TLS 加密后,再通过 TCP 协议进行传输,这样就保证了数据在网络传输中的安全性。
介绍 http2 和 http3 相关知识。 HTTP/2 是 HTTP 协议的第二个主要版本,相比 HTTP/1.1 有很多显著的改进。它采用了二进制分帧层,将数据分割成更小的帧进行传输,提高了传输效率,并且支持多路复用,能在同一个连接上同时处理多个请求和响应,避免了 HTTP/1.1 中的队头阻塞问题,大大提高了页面的加载速度。HTTP/2 还支持服务器推送,服务器可以主动向客户端推送资源,减少了客户端的请求次数。
HTTP/3 是 HTTP 协议的最新版本,它基于 UDP 协议,使用了 QUIC(快速 UDP 互联网连接)协议。QUIC 在 UDP 之上实现了类似 TCP 的可靠性、流量控制等功能,同时又具有 UDP 的低延迟特性。HTTP/3 解决了 HTTP/2 在 TCP 连接下存在的一些问题,如连接建立延迟、队头阻塞等,进一步提高了性能和可靠性。它在移动网络等环境下表现出更好的适应性,能更快地建立连接,更快地传输数据,为用户提供更好的网络体验。
列举你用到过的设计模式,并说明代理模式的好处,以及代理类是否可以通用? 常见的设计模式有单例模式、工厂模式、观察者模式、策略模式等。
代理模式是为其他对象提供一种代理以控制对这个对象的访问。代理模式的好处有很多,首先是远程代理,它可以代替客户端访问远程服务器,隐藏了远程服务器的复杂性和细节,客户端只需要与代理对象交互,提高了代码的可维护性和可扩展性。其次是虚拟代理,比如在加载大图片时,先使用代理对象占位,当真正需要显示图片时再加载真实的图片,这样可以提高程序的启动速度,减少资源的浪费。还有保护代理,可以对目标对象的访问进行控制和验证,只有符合条件的请求才能访问目标对象,增强了系统的安全性。
代理类是否通用取决于具体的设计和实现。在一些情况下,代理类可以具有一定的通用性,比如在网络请求中,使用代理类来统一处理网络请求的缓存、重试等操作,这样的代理类可以在多个不同的网络请求场景中使用。但在某些特定的业务场景下,代理类可能需要针对具体的业务逻辑进行定制,就不具有很强的通用性。
MD5 是对称还是非对称加密? MD5 不是对称加密也不是非对称加密,而是一种哈希算法,也称为散列算法。
哈希算法的特点是将任意长度的数据映射为固定长度的哈希值。MD5 算法会对输入的数据进行一系列复杂的运算,生成一个 128 位的哈希值,通常以 32 位的十六进制数字表示。它具有以下特点:一是不可逆性,通过 MD5 生成的哈希值很难反推出原始数据;二是唯一性,不同的输入数据产生相同哈希值的概率极低,也就是通常所说的 “碰撞” 概率很低。
对称加密和非对称加密都涉及密钥的使用,用于加密和解密数据,而 MD5 不需要密钥,它主要用于数据的完整性校验和数字签名等方面。比如在文件下载中,服务器会提供文件的 MD5 值,用户下载完成后可以计算文件的 MD5 值并与服务器提供的值进行比对,以确保文件在传输过程中没有被篡改。但由于 MD5 存在碰撞概率,在安全性要求较高的场景中,逐渐被更安全的哈希算法如 SHA-256 等所取代。
解释责任链模式,说明设计模式准则
责任链模式是一种行为型设计模式,它将请求的发送者和接收者解耦,通过将多个对象连成一条链,并沿着这条链传递请求,直到有一个对象处理它为止。想象一下,在一个公司的请假流程中,员工提交请假申请,可能先由组长审批,组长审批不了的再交给经理,经理处理不了再交给总监,这就是责任链模式的直观体现。每个处理者都有机会处理请求,若无法处理则传递给下一个处理者。
责任链模式的优点众多。它使得系统更加灵活,新增或移除处理者不会影响其他部分的代码。同时,它提高了代码的可维护性,因为每个处理者的职责明确,代码结构清晰。而且它还能降低耦合度,请求发送者无需知道具体哪个处理者会处理请求。
设计模式准则主要有以下几个:
- 单一职责原则:一个类应该只有一个引起它变化的原因,即一个类只负责一项职责。例如,一个用户管理类,只负责处理用户的注册、登录等相关操作,而不应该同时处理订单相关的业务。这样当用户管理的业务逻辑发生变化时,不会影响到其他不相关的功能。
- 开闭原则:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。这意味着在不修改现有代码的基础上,可以通过扩展新的代码来实现新的功能。比如,在一个图形绘制系统中,已经有绘制圆形、矩形的功能,当需要增加绘制三角形功能时,不需要修改原有的绘制圆形和矩形的代码,而是通过扩展新的三角形绘制类来实现。
- 里氏替换原则:所有引用基类的地方必须能透明地使用其子类的对象。也就是说,子类对象可以替换父类对象,而程序的行为不会发生变化。例如,有一个动物类和它的子类猫类,在程序中使用动物类的地方,都可以用猫类来替代,并且不会出现错误。
- 依赖倒置原则:高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。比如,在一个音乐播放系统中,音乐播放器类(高层模块)不应该直接依赖具体的音乐文件格式类(低层模块),而是依赖一个抽象的音乐播放接口,具体的音乐文件格式类实现这个接口,这样当有新的音乐文件格式时,只需要实现这个接口,而不需要修改音乐播放器类的代码。
介绍了解的设计模式,说明 Kotlin 如何实现单例模式
常见的设计模式有多种,比如工厂模式,它提供了一种创建对象的方式,将对象的创建和使用分离。就像汽车工厂生产汽车,我们只需要向工厂请求汽车,而不需要关心汽车具体是如何制造的。这种模式提高了代码的可维护性和可扩展性,当需要创建不同类型的汽车时,只需要在工厂类中添加相应的创建逻辑即可。
再如观察者模式,它定义了一种一对多的依赖关系,当一个对象状态发生改变时,所有依赖它的对象都会得到通知并自动更新。比如在社交媒体平台上,当一个用户发布了新动态,关注他的其他用户都会收到通知,这就是观察者模式的应用。
在 Kotlin 中实现单例模式有几种方式:
- 饿汉式:在类加载时就创建单例实例,保证了在多线程环境下的唯一性。代码如下:
object Singleton {
// 这里可以定义单例类的属性和方法
val instance: Singleton
get() = this
}
这种方式简单直接,在类加载时就创建了实例,不存在线程安全问题。
- 懒汉式:在第一次使用时才创建实例,实现了延迟加载。使用
by lazy关键字可以很方便地实现:
class Singleton private constructor() {
companion object {
val instance: Singleton by lazy {
Singleton()
}
}
}
by lazy是线程安全的,它会在第一次访问instance时创建实例,并且只会创建一次。
解释闭包的概念
闭包是一个函数以及其词法环境的组合。简单来说,闭包允许一个函数访问并操作其外部作用域的变量,即使这个外部作用域已经结束执行。
想象一下,在一个程序中,有一个函数outerFunction,它内部定义了一个函数innerFunction,并且innerFunction使用了outerFunction中的变量。当outerFunction执行完毕后,正常情况下其内部变量应该被销毁,但由于innerFunction形成了闭包,它可以继续访问和操作这些变量。
以下是一个简单的 Kotlin 示例:
fun outerFunction(): () -> Int {
var count = 0
return fun(): Int {
count++
return count
}
}
val counter = outerFunction()
println(counter()) // 输出 1
println(counter()) // 输出 2
在这个例子中,outerFunction返回了一个匿名函数,这个匿名函数形成了闭包。它可以访问并修改outerFunction中的count变量,即使outerFunction已经执行结束。
闭包的优点在于它可以封装代码逻辑,并且保持对外部变量的持久访问。这在很多场景下非常有用,比如在事件处理中,闭包可以保存事件处理过程中需要的上下文信息。同时,闭包也可以实现数据的隐藏和保护,因为外部代码无法直接访问闭包内部的变量,只能通过闭包提供的函数来操作这些变量。但也要注意,由于闭包会持有外部变量的引用,如果不当使用,可能会导致内存泄漏,特别是在使用完闭包后,没有及时释放对外部变量的引用。
说明跨域访问的概念
跨域访问指的是在浏览器环境下,一个网页的脚本试图访问另一个不同源的资源。这里的 “源” 由协议、域名和端口号组成,只要这三者中有一个不同,就属于不同的源。例如,http://www.example.com和https://www.example.com因为协议不同属于不同源;http://www.example.com和http://api.example.com因为域名不同属于不同源;http://www.example.com:8080和http://www.example.com:8081因为端口号不同属于不同源。
浏览器的同源策略限制了跨域访问,这是一种安全机制,旨在防止恶意脚本从一个源获取另一个源的敏感信息。比如,一个恶意网站不能通过脚本获取用户在银行网站上的登录信息,因为它们属于不同的源。
然而,在实际开发中,有时确实需要进行跨域访问,比如前端应用从不同域名的后端服务器获取数据。为了实现跨域访问,有多种方法:
- JSONP:利用
<script>标签没有跨域限制的特点,通过动态创建<script>标签来请求数据,服务器返回一个包含数据的 JavaScript 函数调用,前端通过解析这个函数调用来获取数据。但 JSONP 只支持 GET 请求,并且安全性相对较低。 - CORS(跨域资源共享):这是一种更现代和安全的跨域解决方案。服务器通过设置响应头,允许特定的源访问其资源。例如,服务器可以设置
Access - Control - Allow - Origin头,指定允许访问的源。这种方式支持多种请求方法,并且安全性较高,是目前主流的跨域解决方案。 - 代理服务器:在前端和后端之间设置一个代理服务器,代理服务器与前端属于同一源,前端请求代理服务器,代理服务器再请求不同源的后端服务器,然后将响应返回给前端。这种方式可以隐藏后端服务器的真实地址,提高安全性,但增加了系统的复杂性。
使用过 Linux 吗?介绍了解的命令行,说明 grep 命令的作用
Linux 是一款广泛使用的开源操作系统,在开发和运维领域应用十分广泛。常见的 Linux 命令行众多,每个都有其独特的用途。
ls命令用于列出目录内容。比如在终端中输入ls,会列出当前目录下的所有文件和子目录。如果想要详细查看文件的权限、所有者、大小等信息,可以使用ls -l命令,这个命令以长格式显示文件和目录的详细信息。
cd命令用于切换目录。例如,要进入名为myfolder的子目录,可以使用cd myfolder命令;如果要返回上一级目录,则使用cd..命令。
mkdir命令用于创建目录。比如要创建一个名为newdir的新目录,输入mkdir newdir即可。如果要创建多级目录,可以使用mkdir -p选项,例如mkdir -p parentdir/childdir,这样会创建parentdir目录,并在其中创建childdir目录。
rm命令用于删除文件或目录。删除单个文件可以使用rm filename,若要删除目录,需要加上-r选项,例如rm -r foldername,这会递归删除目录及其所有内容。
grep命令是一个强大的文本搜索工具,用于在文件或输入流中查找包含指定模式的行。它的基本语法是grep [选项] 模式 [文件]。例如,要在test.txt文件中查找包含 “hello” 的行,可以使用grep "hello" test.txt命令,它会输出test.txt文件中所有包含 “hello” 的行。
grep命令还有很多实用的选项。比如-i选项表示忽略大小写,当你不确定文本中的 “hello” 是大写还是小写时,使用grep -i "hello" test.txt可以查找出包含 “Hello”“HELLO” 等不同大小写形式的行。-r选项用于递归搜索目录及其子目录下的所有文件,例如grep -r "error" /var/log会在/var/log目录及其所有子目录的文件中查找包含 “error” 的行,这在查找系统日志中的错误信息时非常有用。grep命令在系统管理、代码审查等场景中经常被使用,能够快速定位到需要的信息。
广播如何实现应用程序之间的通信?
在 Android 中,广播是一种实现应用程序之间通信的重要机制。Android 系统提供了两种类型的广播:系统广播和自定义广播。
系统广播是由系统在特定事件发生时发出的,比如手机开机、网络连接变化等。应用程序可以通过注册静态或动态广播接收器来接收这些广播。静态注册是在 AndroidManifest.xml 文件中通过<receiver>标签进行注册,这样即使应用程序未运行,也能接收到相关广播。动态注册则是在代码中通过registerReceiver()方法来注册广播接收器,这种方式通常在应用程序运行期间进行,灵活性更高。
自定义广播则是应用程序自己发送的广播,用于在不同的组件或应用之间传递信息。发送自定义广播可以使用sendBroadcast()等方法。接收方同样需要注册广播接收器来接收自定义广播。在广播接收器中,可以通过onReceive()方法来处理接收到的广播。当接收到广播时,onReceive()方法会被调用,在这个方法中可以获取广播携带的信息,并根据具体需求进行相应的处理,比如启动新的 Activity、更新 UI 等,从而实现应用程序之间的通信。
说明布局文件中<include>和<merge>标签的作用。
- **
<include>**标签的作用:<include>标签主要用于在一个布局文件中引入另一个布局文件,实现布局的复用。比如,在多个界面中都有相同的头部或底部布局,就可以将这些相同的部分单独定义为一个布局文件,然后在其他布局文件中通过<include>标签来引入。这样不仅可以减少代码的重复,还方便对公共布局进行统一的修改和维护。在使用<include>标签时,可以通过android:id等属性为引入的布局指定唯一标识,也可以通过android:layout_*系列属性来设置其在父布局中的布局参数。 - **
<merge>**标签的作用:<merge>标签主要用于优化布局层次结构。当一个布局文件作为其他布局的子布局被引入时,如果这个布局文件的根节点是一个没有实际意义的容器(如<LinearLayout>等),只是为了包裹其他子视图,那么就可以使用<merge>标签来替换根节点。这样在引入该布局时,可以减少一层不必要的布局嵌套,从而提高布局的解析效率,提升性能。例如,如果一个布局文件只是简单地包含了几个按钮,使用<merge>作为根节点,就可以让这些按钮直接成为引入它的父布局的子视图,而不需要额外的中间容器。
在 Android 中进行过网络相关编程吗?了解 OkHttp 的底层实现吗?介绍 volley 框架原理。
在 Android 开发中,网络相关编程是非常常见的。通常会使用各种网络框架来实现网络请求和数据传输等功能。
OkHttp 的底层实现主要基于 Java 的网络编程和一些高效的网络协议。它利用了 Socket 进行网络连接,通过连接池管理连接,以减少连接建立的开销。在请求处理方面,OkHttp 支持同步和异步请求。对于异步请求,它使用了线程池来处理请求任务,通过 Dispatcher 来调度请求的执行顺序和并发数量。在缓存机制上,OkHttp 可以根据响应头中的缓存策略来处理缓存数据,提高数据的加载速度和减少网络流量。同时,它还支持 HTTP/2 协议,利用多路复用等特性来优化网络性能。
Volley 框架是 Android 中一个轻量级的网络请求框架。其原理是通过 RequestQueue 来管理请求队列,将所有的网络请求添加到队列中进行统一管理和调度。Volley 支持多种请求方法,如 GET、POST 等,并可以对请求进行封装和配置。在请求执行过程中,Volley 会根据请求的优先级和网络状况等因素,从队列中取出请求并通过网络请求器(如 HttpClient 或 HttpURLConnection)来发送请求。当服务器返回响应时,Volley 会对响应进行解析和处理,将数据传递给对应的回调函数,以便在主线程中更新 UI 或进行其他操作。此外,Volley 还提供了缓存机制,对经常请求的数据进行缓存,提高数据的加载速度。
分析 hashmap 底层原理,如何解决 hash 冲突,说明红黑树的作用。
HashMap 是 Java 中常用的一种数据结构,它基于哈希表实现。其底层原理是通过对键进行哈希运算,得到一个哈希值,然后根据这个哈希值将键值对存储在一个数组中。当需要获取某个键对应的值时,再次对键进行哈希运算,找到对应的数组位置,从而快速获取值。
在 HashMap 中,解决哈希冲突主要有两种方式:链地址法和开放寻址法。在 Java 8 之前,HashMap 主要采用链地址法,即当不同的键计算出相同的哈希值时,会将这些键值对以链表的形式存储在同一个数组位置上。Java 8 及以后,当链表长度达到一定阈值(默认是 8)时,会将链表转换为红黑树来提高查找效率。
红黑树在 HashMap 中的作用主要是为了优化哈希冲突时的查找性能。红黑树是一种自平衡的二叉搜索树,它具有良好的查找、插入和删除性能。当链表长度较长时,查找元素的时间复杂度会退化为 O (n),而红黑树可以将查找、插入和删除的时间复杂度控制在 O (log n)。这样在处理大量哈希冲突时,能够显著提高 HashMap 的性能,保证其在各种情况下都能有较为稳定和高效的操作效率。
说明 list 和 set 各自的特点,分析 arraylist、linkedlist 和 stack 的底层原理,stack 的用途及容量。
- List 和 Set 的特点
- List:是一个有序的集合,允许元素重复。它提供了基于索引的访问方式,方便对元素进行插入、删除和查找操作。常见的实现类有 ArrayList 和 LinkedList 等。
- Set:是一个无序的集合,不允许元素重复。它主要用于存储不重复的数据,并且在判断元素是否存在时具有较高的效率。常见的实现类有 HashSet 和 TreeSet 等。
- ArrayList、LinkedList 和 Stack 的底层原理
- ArrayList:底层是基于数组实现的。它可以动态地扩展和收缩数组的大小。在添加元素时,如果数组容量不足,会进行扩容操作,创建一个更大的数组,并将原来数组中的元素复制到新数组中。在访问元素时,通过索引可以直接快速访问到对应的元素,时间复杂度为 O (1)。但在插入和删除元素时,可能需要移动大量的元素,时间复杂度为 O (n)。
- LinkedList:底层是基于双向链表实现的。每个节点包含了数据元素以及指向前一个节点和后一个节点的引用。在插入和删除元素时,只需要修改相关节点的引用即可,时间复杂度为 O (1)。但在访问元素时,需要从链表的头节点或尾节点开始遍历,时间复杂度为 O (n)。
- Stack:底层是基于数组或链表实现的。它遵循后进先出(LIFO)的原则,就像一个栈一样,最后放入的元素最先取出。在 Java 中,Stack 类继承自 Vector 类,底层是基于数组实现的。它通过一个指针来指向栈顶元素,在进行入栈和出栈操作时,只需要对栈顶指针进行操作,并更新相关元素即可。
- Stack 的用途及容量
- 用途:Stack 常用于需要遵循后进先出原则的场景,比如函数调用栈,在函数调用时,会将函数的相关信息(如参数、局部变量等)压入栈中,当函数执行完毕后,再从栈中弹出这些信息。还可以用于表达式求值、括号匹配等问题的解决。
- 容量:在 Java 中,Stack 的容量默认是 10,如果超过了这个容量,会进行扩容操作。但具体的扩容策略和 ArrayList 类似,会创建一个更大的数组来存储元素。也可以在创建 Stack 时指定初始容量,以满足特定的需求。
介绍 arraylist、hashmap 同步安全的集合类
在 Java 中,有多种针对ArrayList和HashMap的同步安全集合类。
- 针对 ArrayList
- Vector:它是
ArrayList的线程安全版本,对几乎所有的操作都进行了同步处理,通过在方法上使用synchronized关键字来保证在多线程环境下的安全性,但这种方式在并发度较高时可能会影响性能。 - CopyOnWriteArrayList:它在修改操作(如添加、删除元素)时,会创建一个新的底层数组,将修改操作应用到新数组上,最后再将原数组的引用指向新数组。这样读操作就可以无锁进行,大大提高了并发读的性能,适合读多写少的场景。
- Vector:它是
- 针对 HashMap
- Hashtable:类似于
Vector与ArrayList的关系,Hashtable是HashMap的线程安全版本,对方法进行了同步处理。不过,它的性能在高并发场景下也有一定的局限性。 - ConcurrentHashMap:在 JDK1.7 及之前,它采用分段锁的机制,将整个哈希表分成多个段,每个段有自己的锁,不同段的操作可以并发进行,提高了并发度。在 JDK1.8 及之后,采用了 CAS(Compare and Swap)和
synchronized关键字相结合的方式来实现更高效的并发控制,进一步提升了性能。
- Hashtable:类似于
除了 synchronized 以外,还有哪些锁?解释可重入锁的概念,列举死锁的四大条件,说明怎么实现线程,如何避免或解决死锁问题
除了synchronized关键字,Java 中还有以下常见的锁:
- ReentrantLock:可重入锁,它是一种显式锁,需要手动获取和释放锁。与
synchronized不同,它可以更灵活地控制锁的获取和释放时机,还可以实现公平锁和非公平锁等功能。 - ReadWriteLock:读写锁,它将锁分为读锁和写锁,允许多个读操作同时进行,但写操作必须独占。在读多写少的场景下,能大大提高并发性能。
- StampedLock:在 JDK8 中引入,它提供了乐观读、悲观读和写锁等多种模式,性能比
ReadWriteLock更高。
可重入锁是指同一个线程在获取了锁之后,可以再次获取该锁而不会被阻塞。比如一个线程在执行一个synchronized方法时,方法内部又调用了另一个synchronized方法,此时该线程不需要再次获取锁就能进入内部方法,这就是可重入性。
死锁的四大条件包括:
- 互斥条件:资源在某一时刻只能被一个进程或线程占用。
- 请求与保持条件:进程或线程已经持有了一些资源,又请求新的资源,且在等待新资源的同时不释放已持有的资源。
- 不可剥夺条件:资源只能由持有它的进程或线程主动释放,其他进程或线程不能强行剥夺。
- 循环等待条件:多个进程或线程之间形成了一种头尾相接的循环等待资源的关系。
在 Java 中,实现线程有两种常见方式:一是继承Thread类,重写run方法;二是实现Runnable接口,实现run方法,然后将实现类的实例传递给Thread类的构造函数创建线程。
避免或解决死锁问题的方法有:
- 按顺序加锁:所有线程按照固定的顺序获取锁,避免循环等待。
- 资源预分配:在进程或线程开始执行前,一次性分配所有需要的资源。
- 定时锁:给获取锁设置超时时间,避免线程无限期等待。
- 检测与恢复:系统定期检测是否存在死锁,若发现则采取措施进行恢复,如强制释放某些资源。
分析线程之间的锁和进程之间的锁的区别,多线程可以怎么加锁,多进程环境下适用吗?该对象在多进程环境下能访问到吗?列举常见的锁并说明它们的区别及应用场景
线程之间的锁和进程之间的锁存在诸多区别:
- 作用范围:线程锁用于控制同一进程内不同线程对共享资源的访问;进程锁则用于控制不同进程对系统资源的访问。
- 实现机制:线程锁通常基于操作系统的线程同步原语实现,如互斥量、信号量等;进程锁一般通过系统内核提供的进程间通信机制来实现,如文件锁、信号量集等。
- 粒度和性能:线程锁的粒度相对较细,因为线程间共享内存空间,切换成本较低,加锁和解锁的开销较小;进程锁的粒度较粗,进程间通信需要通过内核进行数据拷贝等操作,开销较大。
多线程加锁方式有synchronized关键字、ReentrantLock等。在多进程环境下,这些锁通常不适用,因为多进程有各自独立的地址空间,线程锁无法在进程间共享和生效。一般来说,在多进程环境下,线程锁对象是无法直接被其他进程访问到的。
常见的锁及其区别和应用场景如下:
- 自旋锁:线程在获取锁失败时,不会立即进入阻塞状态,而是不断循环检查锁是否可用。适用于锁的持有时间短、线程上下文切换成本高的场景。
- 互斥锁:同一时刻只允许一个线程或进程访问共享资源,用于保证资源的互斥访问,如多个线程对同一变量的读写操作。
- 信号量:可以控制同时访问共享资源的线程或进程数量,常用于资源池的管理等场景。
说明线程池的调度方式,解释 volatile 的内存语义,说明禁止重排序是如何保证的
线程池的调度方式主要有以下几种:
- FIFO(先进先出):任务按照提交的顺序依次执行,先提交的任务先被执行,这是一种较为简单和常见的调度方式,适用于对任务执行顺序有严格要求的场景。
- 优先级调度:为每个任务设置优先级,线程池会优先执行优先级高的任务。在有不同重要程度的任务时,这种方式能保证重要任务优先得到处理。
- 时间片轮转调度:将时间划分为一个个时间片,每个任务在分配的时间片内执行,时间片用完后,线程池会将任务暂停,切换到下一个任务执行,如此循环。适用于需要公平分配 CPU 时间,且对任务响应时间有一定要求的场景。
volatile关键字的内存语义主要有两个方面:
- 保证可见性:当一个变量被声明为
volatile时,任何线程对它的修改都会立即被其他线程可见。也就是说,当一个线程修改了volatile变量的值,其他线程能马上获取到最新的值,而不会从自己的本地缓存中读取旧值。 - 禁止指令重排序:在 Java 内存模型中,为了提高程序的执行效率,编译器和处理器可能会对指令进行重排序。但
volatile变量会禁止这种重排序优化,保证volatile变量的写操作在其之前的操作都执行完成后才会执行,读操作在其之后的操作都在读取volatile变量之后才会执行。
在 Java 中,禁止重排序主要是通过内存屏障来实现的。对于volatile变量,在写操作时,会在指令序列中插入一个 Store Memory Barrier,它会确保在该屏障之前的所有写操作都已经完成并刷新到主内存后,才会执行后续的指令;在读操作时,会插入一个 Load Memory Barrier,它会保证在执行该屏障之后的读操作之前,所有之前的读操作都已经从主内存中读取到最新的值。通过这些内存屏障的插入,就可以保证volatile变量的操作不会被重排序,从而满足其内存语义。
介绍线程池的创建和内部原理,线程池线程是如何保持线程不被回收的?在 for 循环里一直循环,循环体内会有 take 方法阻塞,为什么这样做?
在 Java 中,创建线程池可以使用ThreadPoolExecutor类的构造函数,也可以通过Executors工具类提供的工厂方法来创建不同类型的线程池,如newFixedThreadPool(创建固定线程数量的线程池)、newCachedThreadPool(创建可缓存线程池)、newScheduledThreadPool(创建定时任务线程池)等。
线程池的内部原理主要涉及以下几个关键部分:
- 线程队列:用于存储提交的任务,当线程池中的线程都在忙碌时,新提交的任务会被放入队列中等待执行。常见的队列有
LinkedBlockingQueue(无界队列)、ArrayBlockingQueue(有界队列)等。 - 线程创建与管理:线程池会根据配置的核心线程数和最大线程数来创建和管理线程。当有新任务提交时,若当前线程数小于核心线程数,会创建新的线程来执行任务;若当前线程数大于等于核心线程数且队列未满,则将任务放入队列;若队列已满且当前线程数小于最大线程数,会创建新的线程来执行任务。
- 任务执行:线程池中的线程从队列中获取任务并执行,执行完一个任务后,会继续从队列中获取下一个任务,直到线程池关闭。
线程池中的线程保持不被回收,是因为线程在执行完任务后,会从任务队列中不断尝试获取新的任务。如果队列中有任务,线程就会继续执行;如果队列为空,线程会根据线程池的配置,在一定时间内等待新任务的到来,而不是立即被回收。
在for循环里使用take方法阻塞,通常是在生产者 - 消费者模型等场景中。take方法用于从队列中获取元素,如果队列为空,线程会被阻塞,直到队列中有元素可用。这样做的目的是为了实现线程之间的同步和协作,让消费者线程在队列没有数据时暂停,避免无效的轮询,节省系统资源,直到生产者线程向队列中放入数据后,消费者线程才被唤醒继续处理数据,从而保证数据的处理顺序和系统的稳定性。
线程可以多次调用 start 吗?会出现什么问题?为什么不能多次调用 start?
在 Java 和 Android 中,线程不可以多次调用start方法。当一个线程调用start方法后,它会进入就绪状态,等待 CPU 调度执行。如果再次调用start方法,会抛出IllegalThreadStateException异常。这是因为线程的状态在第一次调用start后就已经改变,底层的线程启动逻辑已经执行过了,再次调用会导致状态混乱和不确定的行为。Java 的线程实现是基于操作系统的原生线程,底层资源的分配和初始化在第一次start时就完成了,再次调用会破坏这种已经建立好的状态,可能导致资源冲突、数据不一致等问题,所以 Java 语言规范禁止了这种操作。
介绍 RecyclerView 的优化方法
- ViewHolder 复用:使用
ViewHolder模式可以避免每次都重新创建视图,通过缓存视图持有者,在onBindViewHolder中快速绑定数据,提高列表的滚动性能。 - 数据预取:可以在
RecyclerView的LayoutManager中启用预取功能,提前加载即将显示的 item 数据,减少用户滚动时的等待时间。 - 优化 item 布局:尽量减少 item 布局的层级深度,避免使用复杂的嵌套布局,以降低布局测量和绘制的时间。
- 图片加载优化:使用图片加载框架,如 Glide 等,设置合适的图片尺寸和缓存策略,避免加载过大的图片导致内存占用过高和卡顿。
- 局部刷新:如果数据有更新,尽量使用
notifyItemChanged等方法进行局部刷新,而不是notifyDataSetChanged进行全量刷新,提高刷新效率。 - 减少动画和过度绘制:避免在
RecyclerView中使用过多复杂的动画效果,如果需要动画,确保其不会影响滚动性能。同时,通过分析布局的绘制情况,减少不必要的绘制操作,降低 GPU 的压力。
阐述 MVP 的实现方式,以及各部分之间的关系和职责
MVP 即 Model-View-Presenter 架构模式。
- Model:负责处理数据的获取和存储,与数据库、网络等进行交互,提供数据给 Presenter。比如在一个登录功能中,Model 可能会包含从服务器验证用户登录信息的逻辑。
- View:通常是指 Activity、Fragment 等视图层组件,负责展示数据和与用户进行交互,将用户的操作传递给 Presenter。比如在登录界面中,View 负责显示用户名和密码输入框、登录按钮等,并将用户点击登录按钮等操作通知给 Presenter。
- Presenter:作为中间桥梁,连接 Model 和 View。它从 View 获取用户操作,调用 Model 的方法获取数据,然后将数据处理后传递给 View 进行展示。在登录功能中,Presenter 接收 View 传来的用户名和密码,调用 Model 的登录方法,根据返回结果通知 View 显示登录成功或失败的提示。
MVP 的实现方式一般是 View 持有 Presenter 的引用,Presenter 持有 Model 和 View 的引用,通过接口来实现各层之间的解耦,使得代码结构更加清晰,易于维护和测试。
说明 MVM 的实现方式,以及 ViewModel 的底层原理
MVM 即 Model-View-Model 架构模式。
- Model:和 MVP 中的 Model 类似,负责数据的获取和处理,比如从网络获取数据、从本地数据库读取数据等。
- View:依然是视图层组件,通过数据绑定等方式与 ViewModel 进行交互,展示数据和响应用户操作。
- ViewModel:用于存储和管理 UI 相关的数据,它与 View 进行数据绑定,当数据发生变化时,会自动更新 View。ViewModel 的底层原理主要基于 LiveData、DataBinding 等技术。LiveData 是一种可观察的数据持有类,它可以感知组件(如 Activity、Fragment)的生命周期,当数据变化时,只会通知处于活跃状态的组件进行更新。DataBinding 则是一种数据绑定框架,它可以在布局文件中直接绑定 ViewModel 中的数据和方法,实现数据和视图的双向绑定。ViewModel 通过
ViewModelProvider来创建和获取,它的生命周期与相关的组件绑定,在组件的生命周期内保持数据的一致性和稳定性,避免了因配置变化等原因导致的数据丢失和重复加载问题。
介绍热修复框架底层的原理,如 Tinker、美团的框架
热修复框架的主要目的是在不重新发布应用的情况下,修复应用中的 Bug 或更新部分代码和资源。
- Tinker 的原理:Tinker 主要是通过 Dex 分包和 Dex 合并技术来实现热修复。在应用构建时,将应用的代码和资源进行分包处理。当有热修复需求时,服务器会下发修复后的 Dex 文件,Tinker 会在应用启动时,将新的 Dex 文件与已经加载的 Dex 文件进行合并,替换掉有问题的代码或资源。同时,Tinker 还会利用反射等技术来加载新的类和方法,实现代码的动态替换和修复。
- 美团热修复框架的原理:美团的热修复框架(如 Robust)主要是基于 ASM 字节码插桩技术。在编译期间,通过 ASM 框架对字节码进行修改,在方法调用的前后插入一些代码逻辑,用于实现热修复的功能。当有修复补丁时,框架会根据补丁中的信息,将修复后的代码逻辑替换掉原来有问题的代码逻辑,从而达到热修复的目的。这种方式可以在不重新启动应用的情况下,动态地修改方法的执行逻辑,提高了修复的及时性和灵活性。
说明 pid 与 uid 的区别
- 含义
- PID(Process Identifier):即进程标识符,是操作系统分配给每个正在运行进程的唯一数字标识。在 Android 系统中,每当一个应用程序启动,系统就会为其分配一个独立的 PID,用于在系统中对该进程进行跟踪、管理和调度等操作。通过 PID,系统可以清楚地知道每个进程的状态、资源占用情况等信息,从而实现对整个系统资源的合理分配和管理。
- UID(User Identifier):即用户标识符,用于标识系统中的不同用户或应用程序所属的用户身份。在 Android 系统中,每个应用在安装时都会被分配一个唯一的 UID。应用所创建的进程默认会继承该应用的 UID。UID 主要用于实现系统的安全机制,它决定了应用或进程对系统资源的访问权限。
- 作用
- PID 作用:主要用于系统对进程的管理和控制。比如,当需要杀死某个进程以释放资源或终止异常程序时,系统就会根据 PID 来操作。此外,在查看系统进程列表、监控进程状态以及进行进程间通信等操作时,PID 都是重要的标识依据。
- UID 作用:主要用于权限管理。具有不同 UID 的应用或进程,在访问文件、网络资源、系统服务等方面会受到不同的限制。只有具有相应权限的 UID 才能访问特定的资源或执行特定的操作,这样可以有效防止应用之间的非法访问和数据泄露,保障系统的安全性和稳定性。
分析进程和线程的区别,进程状态图转换
- 进程和线程的区别
- 资源分配:进程是系统进行资源分配和调度的基本单位,每个进程都有独立的地址空间、内存、数据栈等资源。而线程是进程中的执行单元,多个线程共享所属进程的资源,如内存空间、文件描述符等。
- 独立性:进程具有较高的独立性,不同进程之间的资源相互隔离,一个进程的崩溃通常不会影响到其他进程。线程的独立性相对较弱,同一进程内的线程之间可以直接访问共享资源,如果一个线程出现问题,可能会影响到整个进程。
- 开销:创建和销毁进程的开销较大,因为涉及到资源的分配和回收等操作。而创建和销毁线程的开销相对较小,只需要进行一些简单的寄存器和栈的操作。
- 并发性:进程之间可以并发执行,操作系统通过调度算法来切换不同进程的执行。线程也可以并发执行,而且在多核处理器环境下,同一进程内的多个线程可以同时在不同的核心上运行,提高了程序的执行效率。
- 进程状态图转换
- 就绪态:进程已经获得了除 CPU 以外的所有必要资源,只要得到 CPU 资源,就可以立即执行,处于等待 CPU 调度的状态。
- 运行态:进程正在 CPU 上执行,此时进程正在占用 CPU 资源,执行其程序代码。
- 阻塞态:进程由于等待某些事件的发生,如 I/O 操作完成、等待信号量等,而暂时无法继续执行,进入阻塞状态。当所等待的事件发生后,进程会从阻塞态转换为就绪态,等待 CPU 调度再次执行。
说明进程间通信方式,讲解 Binder 原理
- 进程间通信方式
- Intent:主要用于在不同组件(如 Activity、Service、Broadcast Receiver 等)之间传递数据和启动组件,它可以携带一些简单的数据类型,如字符串、整数等。
- Bundle:常与 Intent 一起使用,用于传递更复杂的数据结构,如可以将多个键值对数据封装在 Bundle 中进行传递。
- 共享文件:多个进程可以通过读写同一个文件来实现数据共享和通信,但需要注意文件读写的并发控制,以防止数据冲突和不一致。
- Content Provider:用于在不同的应用程序之间共享数据,它提供了一套标准的接口来进行数据的增删改查操作,比如系统的联系人数据就是通过 Content Provider 来供其他应用访问的。
- Socket:可以实现不同设备或同一设备上不同进程之间的网络通信,常用于实现网络应用程序之间的通信。
- Binder 原理
- Binder 是 Android 系统中一种高效的进程间通信机制,基于 C/S 架构。从内核角度看,Binder 驱动程序在内核空间创建了一块共享内存,用于存储通信数据。当客户端进程需要向服务端进程发送数据时,首先将数据从用户空间拷贝到内核空间的 Binder 缓存区,然后通过 Binder 驱动通知服务端进程有数据到达。服务端进程从内核空间的 Binder 缓存区读取数据到自己的用户空间,完成一次通信过程。
介绍 Android 性能优化的方式(布局优化、卡顿优化),分析 Android 卡顿优化的思路和实现方法
- 布局优化
- 减少布局层级:尽量使用相对布局和帧布局等简单布局,避免过多的嵌套布局,如 LinearLayout 嵌套 RelativeLayout 等多层嵌套,可通过 merge 标签和 include 标签来优化布局结构,减少不必要的层级。
- 使用 ViewStub:对于一些不常用或在特定条件下才显示的布局,可以使用 ViewStub 来延迟加载,它在加载时不会占用过多资源,只有在需要显示时才会加载布局。
- 复用布局:将一些通用的布局提取出来,使用 include 标签进行复用,避免在多个地方重复编写相同的布局代码,提高布局的可维护性和性能。
- 卡顿优化
- 优化主线程任务:避免在主线程中执行耗时操作,如网络请求、大量数据的计算等,应将这些操作放在子线程中进行。可以使用 AsyncTask、HandlerThread 或线程池等方式来处理子线程任务。
- 合理使用内存:及时释放不再使用的资源,避免内存泄漏。对于大图片等占用内存较大的资源,要进行适当的压缩和缓存处理,可使用 Glide 等图片加载框架来优化图片加载和缓存。
- 优化渲染:减少过度绘制,检查布局中是否有不必要的重叠视图,尽量使视图布局紧凑。同时,对于自定义视图,要合理重写 onDraw 方法,避免在绘制过程中进行大量复杂的计算。
有用过 CPU Profiler 吗?火焰图怎么看?
- CPU Profiler:是 Android 开发中用于分析应用 CPU 使用情况的工具。通过它可以直观地看到应用中各个函数的执行时间、调用频率等信息,帮助开发者找出应用中可能存在的性能瓶颈。在 Android Studio 中就可以方便地使用 CPU Profiler,启动后它会记录应用的 CPU 活动情况,并以图形化的方式展示出来。
- 火焰图:是一种用于展示 CPU 调用栈信息的可视化工具。火焰图的每一层代表一个函数调用,从下往上是函数的调用关系。火焰图的宽度表示函数的执行时间,越宽说明该函数执行时间越长。通过观察火焰图,可以快速定位到哪些函数占用了大量的 CPU 时间。如果火焰图中某个函数的条带很宽,且处于调用栈的较上层,说明该函数可能是导致 CPU 性能问题的关键所在,开发者可以进一步分析该函数的代码逻辑,看是否存在优化空间,比如是否有不必要的循环、复杂的计算等。
怎么抓包?介绍 Charles、Mock 等抓包工具
抓包就是捕获网络传输过程中的数据包,以便分析和调试网络通信。常见的抓包方式有使用专门的抓包软件如 Charles、Fiddler 等,也可以在 Android 系统中利用一些命令行工具如 tcpdump 等,还可以通过一些 APP 开发框架提供的抓包功能来实现。
Charles
- Charles 是一款常用的 HTTP 网络抓包工具,支持 Windows、Mac 等多种平台。它通过在电脑上搭建一个代理服务器,手机或其他设备设置代理指向该服务器,从而拦截和分析设备与服务器之间的网络请求和响应。可以查看请求和响应的详细信息,包括 URL、请求头、响应头、请求体、响应体等,还能对请求进行断点调试,修改请求参数后再发送,方便测试接口的不同情况。比如在测试支付接口时,可修改金额参数来测试不同金额的支付情况。此外,Charles 还支持 SSL 代理,能解密 HTTPS 请求,让开发者查看加密后的内容。
Mock
- Mock 并不是严格意义上的抓包工具,它主要用于模拟网络请求和响应。在开发过程中,当后端接口还未完成时,前端开发人员可以使用 Mock 工具来模拟后端返回的数据,以便进行前端页面的开发和调试。可以根据定义的规则生成各种模拟数据,比如模拟一个获取用户信息的接口,返回不同用户的姓名、年龄、头像等数据。一些 Mock 工具还支持可视化操作,方便开发人员快速创建和管理模拟接口。与抓包工具不同,Mock 是主动创建数据来模拟网络通信,而抓包工具是被动捕获实际的网络通信数据。
说明 HTTPS 如何实现安全传输,包括中间人攻击相关内容
HTTPS 即超文本传输安全协议,它通过 SSL/TLS 协议来实现安全传输。
- 加密通信:客户端与服务器在建立连接时,会进行 SSL/TLS 握手过程。服务器向客户端发送公钥,客户端使用公钥对要发送的数据进行加密,服务器收到加密数据后,使用私钥进行解密。这样即使数据在传输过程中被拦截,没有私钥也无法解密查看内容。
- 数字证书:为了确保公钥确实属于目标服务器,防止中间人替换公钥进行攻击,服务器会提供由权威证书颁发机构(CA)颁发的数字证书。客户端可以验证证书的合法性,包括证书是否由可信的 CA 颁发、是否在有效期内、证书中的域名是否与访问的域名一致等。
- 数据完整性校验:在数据传输过程中,会使用消息认证码(MAC)等技术对数据进行完整性校验。接收方收到数据后,会根据约定的算法计算 MAC 值,并与发送方发送的 MAC 值进行比较,如果不一致,则说明数据在传输过程中被篡改过。
中间人攻击是指攻击者在客户端和服务器之间插入自己,拦截、篡改或监听双方的通信。在 HTTPS 中,如果攻击者没有服务器的私钥,就无法解密客户端发送的加密数据。同时,客户端对服务器数字证书的验证也能防止攻击者冒充服务器。但如果攻击者通过某种手段获取了合法的数字证书,或者客户端没有严格验证证书的合法性,就可能导致中间人攻击成功。比如攻击者通过钓鱼网站使用伪造的证书来欺骗用户,用户如果没有仔细查看证书信息就可能上当受骗。
说明 DNS 的原理和过程,如何防止 DNS 污染
DNS 即域名系统,它的作用是将人类易于记忆的域名转换为计算机能够识别的 IP 地址。
- DNS 原理:当用户在浏览器中输入一个域名时,浏览器首先会检查本地的 DNS 缓存,如果缓存中有该域名对应的 IP 地址,就直接使用该 IP 地址向服务器发送请求。如果本地缓存中没有,则会向本地 DNS 服务器发送查询请求。本地 DNS 服务器会先检查自己的缓存和区域文件,如果有记录就返回给客户端。如果没有,它会向根 DNS 服务器发送查询请求,根 DNS 服务器会根据域名的顶级域名(如.com、.cn 等),告诉本地 DNS 服务器应该去哪个顶级域名服务器查询。本地 DNS 服务器再向对应的顶级域名服务器发送请求,顶级域名服务器根据域名的二级域名等信息,告诉本地 DNS 服务器应该去哪个权威 DNS 服务器查询。最后,本地 DNS 服务器从权威 DNS 服务器获取到域名对应的 IP 地址,并返回给客户端,客户端就可以使用该 IP 地址与服务器建立连接并进行数据传输。
- 防止 DNS 污染:可以使用一些加密的 DNS 协议,如 DNS over TLS(DoT)和 DNS over HTTPS(DoH),它们通过加密 DNS 查询和响应数据,防止攻击者在传输过程中篡改或窃取 DNS 信息。还可以选择可靠的 DNS 服务提供商,一些知名的 DNS 服务提供商有更完善的安全机制和防护措施,能有效防止 DNS 污染。另外,用户也可以在设备上配置静态 DNS,将 DNS 服务器设置为可靠的地址,而不是使用默认的运营商提供的 DNS 服务器,这样可以减少被 DNS 污染的风险。
平时自己怎么学习安卓的,有什么方式
- 阅读官方文档:Android 官方文档是学习安卓的最佳资源之一,内容全面且权威,涵盖了从基础概念到高级技术的各个方面,比如开发工具的使用、各种组件的介绍、应用开发的最佳实践等。通过仔细阅读官方文档,能深入理解 Android 系统的架构和开发原理。
- 参考开源项目:在 GitHub 等代码托管平台上有大量优秀的 Android 开源项目。可以通过阅读这些项目的代码,学习优秀的代码结构、设计模式和编程习惯。比如一些知名的开源 APP,其界面设计、数据处理、网络请求等方面都有很多值得借鉴的地方。还可以参与开源项目的贡献,通过与其他开发者交流和合作,提升自己的开发能力。
- 在线课程学习:网上有许多专业的 Android 在线课程,如慕课网、网易云课堂等平台上的课程。这些课程通常由经验丰富的讲师授课,内容系统且讲解详细,从入门到进阶都有不同的课程可供选择。可以根据自己的水平和需求进行学习,课程中还会有实际的项目案例,帮助巩固所学知识。
- 参加技术论坛和社区:关注一些 Android 技术论坛和社区,如 Stack Overflow、掘金、CSDN 等。在这些地方可以与其他开发者交流经验、分享自己的学习心得和遇到的问题。还能了解到最新的行业动态和技术趋势,学习他人的解决方案和思路,拓宽自己的技术视野。
说明项目的亮点(架构、可扩展性),项目难点是什么(设计架构、设置 Qt 信号、为集成其他奇怪写法的模块设计一个好的继承方式等)
- 项目亮点
- 架构方面:采用了 MVP 或 MVVM 等先进的架构模式,将视图、业务逻辑和数据模型进行了清晰的分离。比如在 MVP 架构中,View 层负责展示界面和与用户交互,Presenter 层处理业务逻辑并与 Model 层进行数据交互,Model 层负责数据的获取和存储。这样的架构使得代码结构清晰,易于维护和扩展。不同模块之间的职责明确,降低了代码的耦合度,提高了代码的可复用性。
- 可扩展性方面:在设计项目时,充分考虑了未来功能的扩展需求。采用了模块化的设计思想,将项目分为多个独立的模块,每个模块负责一个特定的功能。当需要添加新功能时,可以在不影响其他模块的情况下,在相应的模块中进行扩展。同时,使用了接口和抽象类等技术,为不同模块之间的交互提供了统一的标准,方便后续添加新的模块或替换现有的模块。
- 项目难点及解决方案
- 设计架构:在项目初期,需要根据项目的功能需求和业务特点,选择合适的架构模式,并设计出合理的模块划分和交互方式。这需要对各种架构模式有深入的理解,并且要考虑到项目的复杂性和可扩展性。可以通过参考一些优秀的开源项目和行业经验,进行多次的架构设计和评审,不断优化架构方案。
- 设置 Qt 信号:在使用 Qt 进行开发时,信号与槽机制是实现对象间通信的重要方式。但在设置 Qt 信号时,可能会遇到信号连接失败、信号参数传递错误等问题。需要仔细检查信号和槽的定义是否匹配,信号发送和接收对象的生命周期是否正确,以及信号参数的类型和数量是否一致。可以通过打印调试信息、使用 Qt 的信号槽调试工具等方式来排查问题。
- 为集成其他奇怪写法的模块设计一个好的继承方式:当集成其他模块时,如果模块的代码风格和设计方式与现有项目不一致,会给集成带来困难。可以通过创建适配器类或包装类的方式,将其他模块的接口进行封装,使其符合现有项目的接口规范。同时,在继承方式上,可以根据具体情况选择合适的继承策略,如多重继承、组合等,以实现模块的功能集成和代码的兼容性。
分析 hashcode 与 equals 的关系
在 Java 中,hashCode() 和 equals() 是 Object 类的两个重要方法,它们在对象比较和哈希集合(如 HashMap、HashSet)的使用中起着关键作用。
equals() 方法用于判断两个对象在逻辑上是否相等,默认情况下,它比较的是两个对象的内存地址,即只有当两个对象是同一个对象时才返回 true 。但在实际应用中,我们通常需要根据对象的属性来判断其是否相等,所以经常会重写 equals() 方法。例如,对于一个表示用户的类 User,如果两个 User 对象具有相同的用户名和密码,我们可能认为它们是相等的,就需要重写 equals() 方法来实现这种逻辑。
hashCode() 方法则返回一个对象的哈希码值,这个值是一个整数,用于在哈希集合中快速定位对象。哈希码的计算通常基于对象的属性,不同的对象可能有不同的哈希码,但也可能存在哈希冲突,即不同对象产生相同的哈希码。
它们之间的关系十分紧密:
- 相等的对象必须具有相等的哈希码:如果两个对象通过
equals()方法比较返回true,那么它们的hashCode()方法返回的值也必须相等。这是因为在哈希集合中,首先通过哈希码来快速定位对象的存储位置,如果相等的对象哈希码不同,就可能导致在哈希集合中无法正确找到对应的对象。 - 哈希码相等的对象不一定相等:由于哈希冲突的存在,不同的对象可能会产生相同的哈希码。所以仅仅根据哈希码相等,并不能判断两个对象在逻辑上是相等的,还需要通过
equals()方法进一步验证。
在使用哈希集合时,正确实现 hashCode() 和 equals() 方法非常重要。例如,当我们向 HashSet 中添加对象时,HashSet 首先会根据对象的哈希码来确定其存储位置,然后通过 equals() 方法来判断该位置上是否已经存在相等的对象,如果存在则不再添加,从而保证集合中元素的唯一性。
分析 wait 与 notify 的作用
wait() 和 notify() 是 Java 多线程编程中用于线程间通信的重要方法,它们都定义在 Object 类中,意味着任何对象都可以调用这两个方法来实现线程间的协作。
wait() 方法的作用是让当前线程进入等待状态,并释放它所持有的对象锁。当一个线程调用某个对象的 wait() 方法后,该线程会被挂起,直到其他线程调用同一个对象的 notify() 或 notifyAll() 方法将其唤醒。这种机制常用于实现生产者 - 消费者模型等场景,例如在一个生产 - 消费队列中,当队列已满时,生产者线程调用 wait() 方法进入等待状态,释放锁,让消费者线程有机会从队列中取出元素。
notify() 方法用于唤醒在该对象上等待的单个线程。当一个线程调用 notify() 方法时,会从等待该对象的线程队列中随机选择一个线程,将其唤醒并使其进入可运行状态。被唤醒的线程会尝试重新获取对象锁,一旦获取到锁,就会从 wait() 方法调用处继续执行。
notifyAll() 方法与 notify() 类似,但它会唤醒所有在该对象上等待的线程。所有被唤醒的线程都会竞争对象锁,最终只有一个线程能获取到锁并继续执行,其他线程则继续等待。在某些场景下,例如多个线程都在等待某个条件满足时,使用 notifyAll() 可以确保所有相关线程都有机会被唤醒并处理相应的任务。
需要注意的是,wait()、notify() 和 notifyAll() 方法必须在同步代码块(synchronized 块)中调用,否则会抛出 IllegalMonitorStateException 异常。这是因为这些方法依赖于对象的锁机制来实现线程间的同步和通信。
什么算法用到了单链表?
单链表是一种常见的数据结构,许多算法都利用了它的特性来解决各种问题。
- 链表反转算法:这是一个经典的算法,用于将给定的单链表反转。其核心思想是通过改变链表节点之间的指针方向来实现。例如,对于链表
1 -> 2 -> 3 -> null,经过反转后变为3 -> 2 -> 1 -> null。在实现过程中,通常需要遍历链表,逐个改变节点的指针指向。这种算法在链表操作的相关问题中经常用到,比如在某些加密算法中,可能需要对数据进行反转操作,单链表反转算法就可以提供一种有效的实现方式。 - 约瑟夫环问题:这是一个数学问题,假设有
n个人围成一圈,从第k个人开始报数,数到m的人出圈,然后从出圈的下一个人开始重新报数,直到所有人出圈。可以使用单链表来模拟这个过程,每个节点代表一个人,通过遍历链表并删除节点来模拟人员出圈的过程。这种方法利用了单链表的动态性和易于删除节点的特点,相比其他数据结构,能更直观地解决这个问题。 - 链表排序算法:如归并排序算法可以应用于单链表。归并排序的基本思想是将链表不断地分成两半,然后对每一半进行排序,最后将排序好的两半合并起来。在单链表中,通过快慢指针找到链表的中间节点,将链表分成两部分,再递归地对两部分进行排序和合并。这种方法利用了单链表的链式结构,避免了在数组中进行大量的数据移动操作,提高了排序效率,特别是对于大规模数据的链表排序。
分析内存泄漏和内存溢出的原因及区别
内存泄漏和内存溢出是在编程中常见的与内存相关的问题,它们有着不同的原因和表现。
内存泄漏指的是程序在申请内存后,无法释放已申请的内存空间,导致这部分内存无法被再次使用,随着程序的运行,泄漏的内存会逐渐积累,最终可能导致系统内存不足。其常见原因包括:
- 对象之间的循环引用:例如,两个对象相互持有对方的引用,使得垃圾回收器无法回收它们。假设类
A中有一个成员变量引用了类B的对象,而类B中又有一个成员变量引用了类A的对象,当这两个对象不再被其他对象引用时,由于它们之间的循环引用,垃圾回收器无法判断它们是否可以被回收,从而导致内存泄漏。 - 资源未及时释放:在使用一些资源(如文件句柄、数据库连接等)后,如果没有及时关闭或释放这些资源,就会造成内存泄漏。比如在 Java 中,使用
InputStream读取文件后,没有调用close()方法关闭流,就可能导致内存泄漏。
内存溢出则是指程序在申请内存时,系统没有足够的内存空间分配给它,从而抛出 OutOfMemoryError 异常。其常见原因有:
- 内存分配不合理:程序申请的内存超过了系统所能提供的最大内存限制。例如,在创建一个非常大的数组或集合时,如果系统内存不足,就会发生内存溢出。
- 内存泄漏导致的内存溢出:随着内存泄漏的不断积累,可用内存越来越少,最终导致程序在申请内存时发生内存溢出。
两者的区别在于,内存泄漏是内存使用不当,导致内存无法被回收;而内存溢出是系统没有足够的内存来满足程序的内存申请需求。内存泄漏是内存溢出的一个潜在原因,但不是唯一原因。在开发过程中,需要通过合理的内存管理和代码设计来避免内存泄漏和内存溢出问题,确保程序的稳定性和性能。
列举有用过的框架,是否看过它们的源码?平时有用过什么开源项目?
在 Android 开发中,我使用过多种框架,并且对部分框架的源码进行过深入研究。
- OkHttp:这是一个广泛使用的 HTTP 客户端框架,用于在 Android 应用中进行网络请求。它具有高效、稳定、可扩展性强等优点。我不仅在项目中频繁使用它来处理各种网络请求,还仔细阅读过它的源码。通过阅读源码,我深入了解了其内部的连接池管理、请求队列调度、缓存机制以及如何支持 HTTP/2 等协议。例如,OkHttp 的连接池通过复用已有的连接,减少了连接建立的开销,提高了网络请求的效率,这种优化策略在源码中得到了很好的体现。
- Glide:一款强大的图片加载框架,用于在 Android 应用中加载和显示图片。它具有自动缓存、图片格式优化、加载动画支持等功能,大大简化了图片加载的流程。我在实际项目中利用它来处理各种图片加载需求,同时也研究过它的源码。Glide 的源码中,图片加载流程的设计非常巧妙,通过构建复杂的请求链来处理不同类型的图片加载任务,包括从网络、本地存储等不同来源加载图片,以及对图片进行缩放、裁剪等处理。
在平时的开发中,我还使用过许多开源项目。例如,RecyclerView 作为 Android 官方提供的用于显示大量数据列表的视图组件,它具有高度的灵活性和可定制性。在项目中,我通过结合不同的 LayoutManager 和 Adapter,实现了各种不同布局和交互效果的列表。另外,ButterKnife 这个开源库也非常实用,它通过注解的方式简化了 Android 视图绑定的过程,减少了大量的样板代码,提高了开发效率。我在项目中使用它来快速绑定视图和处理点击事件等,使得代码更加简洁和易读。
说明 AnsycTask 的原理
AsyncTask是 Android 提供的一个轻量级异步任务类,用于在后台执行任务,并在主线程更新 UI。它的原理基于线程池和消息机制。
AsyncTask内部有一个线程池ThreadPoolExecutor,用于执行后台任务。当调用execute()方法时,任务会被添加到线程池中排队等待执行。同时,AsyncTask还通过Handler来实现主线程和后台线程之间的通信。在后台任务执行过程中,可以通过publishProgress()方法将进度信息发送到主线程,Handler接收到消息后会回调onProgressUpdate()方法来更新 UI。当后台任务执行完成后,会通过Handler发送消息,回调onPostExecute()方法,在这个方法中可以处理后台任务的执行结果。
AsyncTask的生命周期包括onPreExecute()、doInBackground()、onProgressUpdate()、onPostExecute()等方法。onPreExecute()在任务执行前在主线程调用,用于做一些准备工作;doInBackground()在后台线程执行,用于执行耗时任务;onProgressUpdate()在主线程调用,用于更新进度;onPostExecute()在任务完成后在主线程调用,用于处理结果。
介绍 java 内存分区,堆区的结构划分,解释 minor gc、full gc 等概念
Java 内存主要分为以下几个区域:
- 程序计数器:是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器,每个线程都有一个独立的程序计数器。
- Java 虚拟机栈:线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 本地方法栈:与虚拟机栈类似,只不过它是为 Native 方法服务的。
- Java 堆:是 Java 虚拟机所管理的内存中最大的一块,被所有线程共享,几乎所有的对象实例都在这里分配内存。
- 方法区:也是被所有线程共享的区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Java 堆区通常可以进一步划分为新生代和老年代。新生代又分为 Eden 区和两个 Survivor 区(S0 和 S1)。
minor gc是指发生在新生代的垃圾回收动作。由于新生代中的对象大多是朝生夕灭的,所以minor gc会比较频繁。当 Eden 区满了的时候,就会触发minor gc,将存活的对象复制到 Survivor 区,如果 Survivor 区也满了,就会将部分对象晋升到老年代。
full gc是指发生在整个堆区(包括新生代和老年代)的垃圾回收动作。full gc通常比minor gc要慢得多,因为它需要处理整个堆中的对象。触发full gc的情况有很多,比如老年代空间不足、方法区空间不足等。
图片本地缓存的文件夹有大小限制,如果满了缓存文件怎么办
当图片本地缓存文件夹达到大小限制时,通常有以下几种处理方式:
- 删除最久未使用(LRU)的缓存文件:维护一个记录缓存文件使用时间的列表,当缓存满时,删除最早使用的文件,为新的缓存文件腾出空间。这种方式基于一种假设,即最近没有使用过的文件在未来一段时间内也不太可能被使用。
- 删除最少使用(LFU)的缓存文件:记录每个缓存文件的使用频率,当需要清理空间时,删除使用频率最低的文件。这种方式认为使用频率低的文件在未来被使用的可能性也较低。
- 按照文件大小删除:优先删除较大的缓存文件,以尽可能释放更多的空间。但这种方式可能会导致一些较小但经常使用的文件被保留,而一些较大但可能很快会被使用的文件被删除。
- 根据缓存文件的重要性删除:为每个缓存文件设置一个重要性标记,当缓存满时,优先删除重要性较低的文件。例如,对于一些临时展示的图片,可以设置较低的重要性,而对于用户经常访问的关键图片,则设置较高的重要性。
实际应用中,可能会综合使用多种策略。比如先按照 LRU 策略筛选出一批可能删除的文件,然后在这些文件中再根据文件大小或重要性等因素进行进一步的筛选。
说明 vector 与 arraylist 区别
Vector和ArrayList都是 Java 中用于存储和操作数据的集合类,它们有以下一些区别:
- 线程安全性:
Vector是线程安全的,它的方法都被synchronized关键字修饰,多个线程可以安全地访问Vector。而ArrayList是非线程安全的,在多线程环境下,如果没有适当的同步措施,可能会出现数据不一致等问题。 - 性能:由于
Vector的方法是线程安全的,这会带来一定的性能开销。在单线程环境下,ArrayList的性能通常比Vector要好。因为ArrayList不需要进行线程同步的操作。 - 扩容机制:
Vector默认的扩容策略是将容量增加一倍,而ArrayList默认的扩容策略是将容量增加 50%。当然,这两种集合类都可以在创建时指定初始容量和扩容因子。 - 迭代器:
Vector的迭代器是 fail-fast 的,而ArrayList的迭代器在并发修改时也会抛出ConcurrentModificationException异常,但在实现细节上可能有所不同。
在实际应用中,如果是在单线程环境下,并且对性能要求较高,通常会选择ArrayList。如果是在多线程环境下,需要保证线程安全,那么可以选择Vector,或者使用Collections.synchronizedList()方法将ArrayList包装成线程安全的集合。
说明 so 文件加载的过程,如果有一个 A.so 依赖 B.so,在未加载 B.so 的情况下直接加载 A.so 会发生什么?如何避免上述问题
在 Android 中,.so文件是动态链接库文件,其加载过程如下:
- 当应用程序需要使用某个
.so文件中的函数或变量时,首先会通过System.loadLibrary()或System.load()方法来加载.so文件。 System.loadLibrary()方法会根据传入的库名,在系统的库搜索路径中查找对应的.so文件。这些搜索路径包括应用的lib目录等。- 找到
.so文件后,系统会将其加载到内存中,并对其进行初始化,包括解析符号表、分配内存空间等操作。 - 然后,应用程序就可以通过 JNI(Java Native Interface)来调用
.so文件中的函数或访问其中的变量。
如果A.so依赖B.so,在未加载B.so的情况下直接加载A.so,可能会导致A.so中的某些函数或变量无法正确解析,因为这些函数或变量可能是在B.so中定义的。这会导致程序运行时出现错误,比如UnsatisfiedLinkError异常,提示找不到相应的符号。
为了避免这种问题,可以采取以下措施:
- 确保在加载
A.so之前,先加载B.so。可以在代码中按照正确的顺序调用System.loadLibrary()方法来加载依赖的.so文件。 - 使用
CMake或NDK构建系统时,正确配置依赖关系,让构建系统自动处理.so文件的加载顺序。在CMakeLists.txt文件中,可以通过target_link_libraries()等命令来指定库之间的依赖关系,确保在链接A.so时,B.so已经被正确链接。
在原生的线程中调用 attach 方法是为了什么,之后什么情况下要 detach?
在原生线程中调用attach方法主要是为了将原生线程附加到 Java 虚拟机(JVM)上,使得原生线程能够与 Java 环境进行交互,比如调用 Java 方法、访问 Java 对象等。因为在默认情况下,原生线程是没有与 JVM 关联的,通过attach操作,就为原生线程建立了与 JVM 的连接,使其可以使用 JVM 提供的各种功能和服务。
当原生线程完成了与 Java 环境的交互任务,不再需要访问 Java 相关的资源和执行 Java 代码时,就需要调用detach方法来将线程从 JVM 中分离。比如在一些后台任务线程中,当任务执行完毕,如果不及时detach,可能会导致资源无法正确释放,引起内存泄漏等问题。还有在一些多线程并发操作中,如果某个原生线程已经处理完特定的与 Java 交互的逻辑,后续不再需要与 JVM 交互,为了避免不必要的资源占用和潜在的冲突,也应该进行detach操作。
除了名称直接对应 native 方法,还有哪些绑定入口到原生方法的手段?
除了通过方法名称直接对应native方法这种简单直接的绑定方式外,还可以使用JNIEXPORT和JNICALL宏来进行绑定。在 C 或 C++ 代码中,使用这些宏来定义原生方法,明确指定方法的签名和调用约定,使得 Java 层能够正确地找到并调用对应的原生方法。
另外,还可以通过 Java 本地接口的注册机制来绑定。在 JNI 中,可以使用RegisterNatives函数来手动注册原生方法,将 Java 方法和 C/C++ 中的原生方法进行映射。这种方式更加灵活,可以在运行时动态地进行方法绑定,而不仅仅依赖于方法名称的静态对应。通过这种方式,可以更精细地控制原生方法的绑定过程,对于一些复杂的应用场景和需求,比如需要根据不同的条件或配置来选择不同的原生方法实现时,这种注册机制就非常有用。
可以在不同的线程里面使用同一个 JNI env 对象吗?
一般来说,不可以在不同的线程里面使用同一个JNIEnv对象。JNIEnv对象是与特定线程相关联的,它提供了在该线程中与 Java 虚拟机进行交互的各种函数和接口。每个线程都应该有自己独立的JNIEnv对象来保证线程安全和正确的执行。
如果在不同线程中使用同一个JNIEnv对象,可能会导致数据竞争和不一致的问题。因为不同线程可能会同时访问和修改JNIEnv对象中的数据,这可能会破坏 JVM 的内部状态,导致程序出现不可预测的行为,比如崩溃、数据错误等。而且,JVM 对JNIEnv的使用是有线程限制的,在一个线程中获取的JNIEnv只能在该线程中使用,在其他线程中使用是不合法的操作。
简述写一个 JNI HelloWorld 的基本流程,从写出 Java native 方法到打印到手机屏幕上为止,说明这整个过程里面每一步产生了什么文件。
- 首先,在 Java 代码中定义
native方法,比如创建一个HelloWorldJNI类,在其中声明一个native方法public native void sayHello();,此时会产生.java文件,这是 Java 源文件,包含了 Java 代码逻辑。 - 然后,使用
javac命令编译 Java 源文件,生成.class文件,这是 Java 字节码文件,包含了编译后的 Java 代码,JVM 可以加载和执行其中的字节码。 - 接着,通过
javah命令根据.class文件生成 C 或 C++ 的头文件,这个头文件中包含了与 Java 中native方法对应的函数声明,产生的.h文件是 JNI 的头文件,用于在 C/C++ 代码中定义与 Java 交互的接口。 - 之后,编写 C 或 C++ 代码来实现头文件中声明的函数,在实现文件中实现
sayHello方法的具体逻辑,比如使用printf输出 "Hello World" 等,这会产生.c或.cpp文件,是 C 或 C++ 的源文件,包含了原生方法的具体实现。 - 再使用 C 或 C++ 的编译器将
.c或.cpp文件编译成动态链接库文件,在 Android 中通常是.so文件,这是最终可供 Java 层调用的原生库文件。 - 最后,在 Java 代码中通过
System.loadLibrary方法加载.so文件,并调用sayHello方法,就可以在手机屏幕上看到输出的 "Hello World"。
比较动态映射原生方法和直接对应原生方法,分别说出优点和缺点。
- 动态映射原生方法
- 优点:具有很高的灵活性和可扩展性。可以在运行时根据不同的条件或配置来动态地决定映射关系,比如根据设备类型、系统版本等选择不同的原生方法实现。而且可以方便地进行方法替换和更新,不需要重新编译整个项目,只需要更新动态映射的逻辑即可。
- 缺点:实现相对复杂,需要更多的代码来管理和维护映射关系。由于是在运行时进行映射,可能会带来一定的性能开销,比如需要额外的时间来查找和确定映射关系。并且调试相对困难,因为映射关系不是静态确定的,出现问题时很难快速定位。
- 直接对应原生方法
- 优点:实现简单直接,方法的对应关系一目了然,易于理解和维护。在编译时就确定了方法的映射,不存在运行时查找映射的开销,性能相对较好。而且调试也比较容易,因为方法的调用路径比较清晰。
- 缺点:缺乏灵活性,一旦方法名称或签名确定,就很难在不修改大量代码和重新编译的情况下进行更改。如果需要根据不同的情况使用不同的原生方法实现,直接对应方式就很难满足需求,扩展性较差。
当面对只有原生方法的调用栈抛出异常时,可从以下几个方面入手排查:
- 检查参数传递:确认每个原生方法的输入参数是否符合预期。比如在 C 或 C++ 中,方法可能对参数的类型、范围有严格要求。若涉及指针,要确保指针指向的内存地址有效,没有出现空指针引用。例如,在一个处理数组的原生方法中,传入的数组指针必须是已分配内存且大小正确的,否则可能引发Segmentation Fault等错误。
- 查看函数调用顺序:分析调用栈中方法的调用顺序是否正确。有些原生方法的执行依赖于特定的前置条件或其他方法的执行结果。比如在进行文件操作时,需要先调用打开文件的方法,获取有效的文件句柄后,才能进行读取或写入操作,否则会抛出文件操作相关的异常。
- 检查内存管理:在原生代码中,内存管理至关重要。排查是否存在内存泄漏、内存越界等问题。使用malloc、free等函数时,要确保内存分配和释放的正确性。若多次分配内存却未释放,会导致内存泄漏;而访问已释放的内存或超出数组边界访问内存,会引发未定义行为和异常。
- 分析异常类型和错误信息:根据抛出的异常类型和附带的错误信息,能快速定位问题方向。例如,Divide by zero异常表明代码中出现了除零操作;Out of memory异常则提示内存分配失败,可能是系统内存不足,也可能是内存分配逻辑有误。
给一个系统 API 的调用栈 (只有系统方法,没有 APP 里的),应该如何排查问题?
面对只有系统方法的调用栈时,排查问题可参考以下步骤:
- 确认系统版本和兼容性:不同的 Android 系统版本对 API 的实现和行为可能有所差异。首先要确定当前设备的系统版本,查看对应版本的官方文档,了解 API 在该版本中的特性和可能存在的问题。比如某些系统 API 在低版本中可能存在兼容性问题,或者在高版本中功能有所改进。
- 检查依赖和权限:系统 API 的调用可能依赖于其他组件或需要特定权限。例如,访问摄像头的 API 需要CAMERA权限,若未申请该权限,调用相关 API 时会抛出权限不足的异常。同时,要确保相关依赖库已正确引入和配置。
- 分析调用栈信息:仔细研究调用栈中每个系统方法的调用顺序和参数传递。虽然是系统方法,但参数错误同样可能导致异常。比如在调用系统的网络请求 API 时,传入错误的 URL 格式,会导致请求失败并抛出异常。
- 查看系统日志:利用 Android 系统提供的日志工具,如logcat,查看系统日志。日志中可能包含关于异常的详细信息,如异常发生的时间、原因等,这有助于定位问题根源。有时候,系统会在日志中记录一些警告或错误信息,即使没有直接抛出异常,也能为排查问题提供线索。
怎么样设计一个 JNI 的异常捕捉机制?
设计 JNI 的异常捕捉机制时,可从以下几个方面考虑:
- Java 层异常处理:在 Java 代码中调用 JNI 方法时,使用try - catch块来捕获异常。这样可以统一处理 JNI 方法可能抛出的异常,避免异常导致程序崩溃。例如:
try {
// 调用JNI方法
nativeMethod();
} catch (Exception e) {
// 处理异常
e.printStackTrace();
}
- JNI 层异常处理:在 JNI 代码中,使用ExceptionCheck函数来检查是否有异常发生。若有异常,可根据情况进行处理,如打印错误信息、清理资源等。例如:
JNIEXPORT void JNICALL Java_com_example_MyClass_nativeMethod(JNIEnv *env, jobject obj) {
// 执行原生代码
// 检查是否有异常
if ((*env)->ExceptionCheck(env)) {
// 打印异常信息
(*env)->ExceptionDescribe(env);
// 清理资源
// 可以选择继续抛出异常或处理后返回
}
}
- 异常传递:如果在 JNI 层无法处理异常,可以选择将异常传递回 Java 层。使用ExceptionThrow函数将异常抛出,让 Java 层的try - catch块来捕获和处理。这样可以保持异常处理的一致性,将异常处理逻辑集中在 Java 层。
- 自定义异常:根据项目需求,定义自定义的 JNI 异常。在 JNI 代码中抛出自定义异常,然后在 Java 层根据自定义异常类型进行针对性的处理,提高异常处理的灵活性和可维护性。
有哪些 Android 支持的 ABI,怎么样确定手机 CPU 最适合的 ABI?
Android 支持多种应用二进制接口(ABI),主要包括:
- armeabi:支持所有 ARM 设备,是一种通用性的 ABI,但性能相对较低。
- armeabi - v7a:在 armeabi 的基础上进行了扩展,支持 ARMv7 及以上架构的设备,性能有所提升,支持硬件浮点运算等新特性。
- arm64 - v8a:针对 64 位 ARM 架构的设备,性能进一步提升,支持更大的内存寻址空间和更高效的指令集。
- x86:用于基于 x86 架构的设备,如一些使用英特尔处理器的 Android 设备。
- x86_64:对应 64 位的 x86 架构设备。
确定手机 CPU 最适合的 ABI,可以通过以下方法:
- 读取系统属性:在 Android 系统中,可以通过读取ro.product.cpu.abi和ro.product.cpu.abi2等系统属性来获取设备支持的 ABI。例如,在 Java 代码中使用System.getProperty("ro.product.cpu.abi")获取首选 ABI。
- 使用工具检测:一些第三方工具可以检测设备的 CPU 信息和支持的 ABI。例如,CPU - Z等应用可以详细展示设备的 CPU 架构和相关信息,帮助确定最适合的 ABI。
- 兼容性测试:在开发应用时,进行多 ABI 的兼容性测试。将应用打包成支持不同 ABI 的版本,在不同设备上安装运行,观察应用的性能和稳定性,选择性能最佳、兼容性最好的 ABI 作为目标 ABI。
有写过针对特定 ABI 优化的原生代码吗?
在一些对性能要求较高的项目中,可能会针对特定 ABI 进行原生代码优化。例如,针对arm64 - v8a ABI 的优化:
- 利用新指令集:arm64 - v8a架构引入了新的指令集,如 NEON 指令集。在处理图像、音频等数据时,可以使用 NEON 指令集进行并行计算,提高处理速度。例如,在图像滤波算法中,通过 NEON 指令对图像像素进行并行处理,比普通的标量运算要快得多。
- 优化内存管理:64 位架构支持更大的内存寻址空间,但也需要更合理的内存管理。根据arm64 - v8a的内存特性,优化内存分配和释放策略,减少内存碎片,提高内存访问效率。
- 代码结构优化:根据arm64 - v8a的 CPU 特性,调整代码结构。例如,合理安排函数调用顺序,减少函数调用开销;使用更适合 64 位架构的算法和数据结构,提高程序的执行效率。
在进行针对特定 ABI 的优化时,需要充分了解目标 ABI 的特性和优势,结合项目需求,有针对性地进行代码优化,以达到最佳的性能表现。同时,要注意兼容性,确保优化后的代码在其他 ABI 的设备上也能正常运行。
介绍图片的高效加载方法,包括采样率、三级缓存等,是否有别的方法,分析 decodeFile 和 getResource 拿图片的优缺点和区别
- 采样率:通过调整采样率可以减少图片的像素数量,从而降低内存占用。在加载大图片时,根据目标 ImageView 的大小和屏幕分辨率,计算合适的采样率。例如,使用BitmapFactory.Options类的inSampleSize属性,若设为 2,则图片宽高都变为原来的 1/2,内存占用降为原来的 1/4 。
- 三级缓存:即内存缓存、磁盘缓存和网络缓存。内存缓存能快速获取图片,减少 CPU 和内存开销,常用LruCache实现,它基于最近最少使用算法管理缓存;磁盘缓存用于当内存缓存未命中时,从磁盘读取图片,减少网络请求,可使用DiskLruCache实现;网络缓存是前两级缓存都未命中时,从网络加载图片,加载后存入前两级缓存。
- 其他方法:还可使用图片加载框架,如 Glide、Picasso 等,它们封装了复杂的图片加载逻辑,支持自动缓存、图片格式转换、渐进式加载等。渐进式加载能先展示低质量图片,随着加载逐渐变清晰,提升用户体验。同时,在加载图片前对图片进行预处理,如压缩、裁剪,也能减少内存占用和加载时间。
decodeFile和getResource拿图片的区别:
- decodeFile:从文件系统中读取图片,优点是能加载任意路径的图片,适用于加载本地存储的图片;缺点是需手动处理图片路径和权限,且可能因文件过大导致内存溢出。
- getResource:从资源文件中获取图片,优点是使用方便,无需处理路径,系统会自动管理资源,且能根据设备屏幕密度获取合适分辨率的图片;缺点是只能加载项目资源目录下的图片,灵活性较差。
介绍 classLoader,如何打破双亲委托?
ClassLoader即类加载器,是 Java 运行时系统的重要组件,负责动态加载类文件到 Java 虚拟机中。它有三个主要类型:
- 启动类加载器(Bootstrap ClassLoader):由 C++ 实现,负责加载 Java 核心类库,如java.lang包下的类,它是最顶层的类加载器。
- 扩展类加载器(Extension ClassLoader):负责加载 Java 的扩展类库,如jre/lib/ext目录下的类。
- 应用程序类加载器(Application ClassLoader):也叫系统类加载器,负责加载应用程序的类路径下的类,是自定义类加载器的默认父加载器。
双亲委托模型是指类加载器在加载类时,先将加载请求委托给父类加载器,只有父类加载器无法加载时,才由自己尝试加载。这种模型保证了 Java 核心类库的安全性和唯一性。
打破双亲委托机制的方法有:
- 自定义类加载器:继承ClassLoader类,重写loadClass方法,在其中改变类加载的逻辑,不遵循双亲委托的顺序。例如,在实现热插拔功能时,自定义类加载器可以在不重启应用的情况下加载新的类文件,当有新的类更新时,直接加载新类,无需依赖父类加载器。
- 线程上下文类加载器:Java 提供了线程上下文类加载器,通过Thread.currentThread().setContextClassLoader方法设置。在一些框架中,如 JDBC,由于其接口在核心类库中,实现类由第三方提供,通过线程上下文类加载器,能让核心类库中的代码调用由应用程序类加载器加载的实现类,打破了双亲委托模型。
说明一张图片加载到内存,如何计算图片占用内存的大小,说明 ARG8888 与 ARGB565 的区别
计算图片占用内存大小,需考虑图片的分辨率和像素格式。公式为:图片占用内存大小 = 图片宽度 × 图片高度 × 每个像素占用的字节数 。
以常见的ARGB8888和ARGB565像素格式为例:
- ARGB8888:每个像素用 4 个字节表示,A(透明度)、R(红色)、G(绿色)、B(蓝色)各占 8 位,共 32 位。假设一张 100×100 像素的图片,采用ARGB8888格式,其占用内存大小为 100×100×4 = 40000 字节 。
- ARGB565:每个像素用 2 个字节表示,A(透明度)无单独表示,R 占 5 位、G 占 6 位、B 占 5 位,共 16 位。同样 100×100 像素的图片,采用ARGB565格式,占用内存大小为 100×100×2 = 20000 字节 。
二者区别在于:
- 色彩表现:ARGB8888色彩更丰富,能呈现更细腻的颜色过渡和更真实的图像效果;ARGB565色彩相对较少,在表现复杂图像时可能出现色彩偏差。
- 内存占用:ARGB8888内存占用是ARGB565的两倍,在对内存敏感的场景,如移动设备应用,ARGB565可有效减少内存占用,提高应用性能。
描述 APK 打包流程,介绍签名相关知识,res/assets 的作用
APK 打包流程如下:
- 资源处理:通过 AAPT(Android Asset Packaging Tool)工具处理res目录下的资源文件,如布局文件、图片、字符串等,将其编译成二进制格式,生成resources.arsc文件,这个文件包含了应用的所有资源索引和数据。
- Java 代码编译:使用 Java 编译器(如 Javac)将 Java 源文件编译成字节码文件(.class文件)。
- 字节码转换:通过 DX 工具将.class文件转换为 Dalvik 字节码文件(.dex文件),Dalvik 是 Android 早期的虚拟机,现在的 ART(Android Runtime)也兼容.dex文件,这个过程会合并多个.class文件,优化字节码。
- 打包:将resources.arsc文件、.dex文件、assets目录下的资源以及其他文件(如AndroidManifest.xml)一起打包成一个未签名的 APK 文件。
- 签名:使用数字证书对 APK 进行签名,确保 APK 的完整性和来源可靠性,只有签名后的 APK 才能在 Android 设备上安装运行。
签名相关知识:
- 数字证书:是由证书颁发机构(CA)颁发的,包含公钥、私钥、证书持有者信息等。在 APK 签名中,开发者使用私钥对 APK 进行签名,安装时系统用公钥验证签名的合法性。
- 签名作用:防止 APK 被篡改,若 APK 被修改,签名会失效,系统拒绝安装;同时,相同签名的 APK 在一定条件下可共享用户数据和代码,如实现应用的插件化。
res和assets的作用:
- res:存放资源文件,会被 AAPT 工具处理,生成资源索引,在代码中可通过资源 ID 访问,如R.drawable.icon访问图片资源,R.layout.activity_main访问布局资源 。
- assets:存放原始文件,不会被 AAPT 处理,在代码中通过AssetManager访问,常用于存放字体文件、数据库文件、配置文件等无需生成资源 ID 的文件。
介绍四种引用方式及其区别,说明什么时候用到弱引用
Java 中有四种引用方式:
- 强引用(Strong Reference):最常见的引用方式,如Object obj = new Object();,只要强引用存在,对象就不会被垃圾回收器回收,即使内存不足,也会抛出OutOfMemoryError异常。
- 软引用(Soft Reference):通过SoftReference类实现,用于描述一些还有用但非必需的对象。在系统内存不足时,软引用指向的对象会被回收,常用于实现内存敏感的缓存,如图片缓存,当内存紧张时,缓存的图片可被回收释放内存。
- 弱引用(Weak Reference):通过WeakReference类实现,比软引用更弱,只要垃圾回收器扫描到弱引用指向的对象,不管内存是否充足,都会回收该对象。常用于解决内存泄漏问题,如在监听器中,若使用强引用,被监听对象无法被回收,而使用弱引用,当被监听对象不再被其他地方引用时,可正常被回收。
- 虚引用(Phantom Reference):通过PhantomReference类实现,最弱的引用,无法通过虚引用获取对象实例,主要用于跟踪对象被垃圾回收的状态,在对象被回收时,会收到一个系统通知,常用于资源释放和对象销毁的通知场景。
它们的区别主要在于对象被回收的时机和对对象的持有强度。强引用最强,保证对象不被回收;软引用次之,内存不足时回收;弱引用更弱,随时可能被回收;虚引用最弱,仅用于通知对象回收。
介绍 Java 注解相关知识
Java 注解是一种元数据,它提供了一种安全的类似注释的机制,用来将任何的信息或元数据(metadata)与程序元素(类、方法、成员变量等)进行关联。注解可以在编译期、运行时被读取,并执行相应的处理逻辑。
注解的定义
通过@interface关键字来定义注解,例如:
public @interface MyAnnotation {
String value() default "";
int count() default 0;
}
这里定义了一个MyAnnotation注解,它有两个属性value和count,并且都有默认值。
注解的使用
可以在类、方法、字段等上面使用注解,比如:
@MyAnnotation(value = "test", count = 10)
public class MyClass {
@MyAnnotation(count = 5)
public void myMethod() {
// 方法体
}
}
注解的类型
- 标记注解:没有定义成员的注解,仅用于标记某个程序元素,比如@Override,用于标记方法是重写父类的方法。
- 单值注解:只有一个成员的注解,比如@SuppressWarning("unchecked"),其中"unchecked"就是该注解的唯一值。
- 完整注解:包含多个成员的注解,如前面定义的MyAnnotation。
注解的保留策略
通过Retention注解来指定注解的保留策略,有以下三种:
- RetentionPolicy.SOURCE:注解只在源文件中存在,编译时被丢弃,不会保留在字节码文件中,如@Override。
- RetentionPolicy.CLASS:注解会保留在字节码文件中,但在运行时不会被加载到 JVM 中,这是默认的保留策略。
- RetentionPolicy.RUNTIME:注解不仅保留在字节码文件中,在运行时也可以通过反射获取并使用,如@Autowired。
注解的处理器
在运行时,可以通过反射来获取注解信息并进行处理。例如:
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
public class AnnotationProcessor {
public static void main(String[] args) throws Exception {
Class<MyClass> clazz = MyClass.class;
// 获取类上的注解
MyAnnotation classAnnotation = clazz.getAnnotation(MyAnnotation.class);
if (classAnnotation!= null) {
System.out.println("Class annotation - value: " + classAnnotation.value());
System.out.println("Class annotation - count: " + classAnnotation.count());
}
// 获取方法上的注解
Field field = clazz.getDeclaredField("myField");
MyAnnotation fieldAnnotation = field.getAnnotation(MyAnnotation.class);
if (fieldAnnotation!= null) {
System.out.println("Field annotation - value: " + fieldAnnotation.value());
System.out.println("Field annotation - count: " + fieldAnnotation.count());
}
}
}
手撕 LRU
LRU(Least Recently Used)即最近最少使用算法,常用于缓存淘汰策略。以下是一个基于LinkedHashMap实现的 LRU 缓存:
import java.util.LinkedHashMap;
import java.util.Map;
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
// 第三个参数accessOrder设为true表示按照访问顺序排序
super(capacity, 0.75f, true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 当缓存大小超过容量时,移除最久未使用的元素
return size() > capacity;
}
public static void main(String[] args) {
LRUCache<Integer, String> cache = new LRUCache<>(3);
cache.put(1, "one");
cache.put(2, "two");
cache.put(3, "three");
System.out.println(cache); // 输出: {1=one, 2=two, 3=three}
cache.get(2);
System.out.println(cache); // 输出: {1=one, 3=three, 2=two}
cache.put(4, "four");
System.out.println(cache); // 输出: {3=three, 2=two, 4=four}
}
}
算法:实现 String 的 endWith () 方法
endWith()方法用于判断一个字符串是否以指定的后缀结尾。以下是实现代码:
public class StringEndsWith {
public static boolean endsWith(String str, String suffix) {
if (suffix == null || suffix.length() == 0) {
return true;
}
if (str == null || str.length() < suffix.length()) {
return false;
}
int strLen = str.length();
int suffixLen = suffix.length();
for (int i = 0; i < suffixLen; i++) {
if (str.charAt(strLen - suffixLen + i)!= suffix.charAt(i)) {
return false;
}
}
return true;
}
public static void main(String[] args) {
String str = "HelloWorld";
String suffix1 = "World";
String suffix2 = "Java";
System.out.println(endsWith(str, suffix1)); // 输出: true
System.out.println(endsWith(str, suffix2)); // 输出: false
}
}
有 1001 个数,分别是 0 - 1000,有一个数重复,尽可能快找出来,不破坏原来的数组(java 代码完整实现)
可以利用异或运算的特性来解决这个问题。异或运算满足交换律和结合律,且一个数与自身异或结果为 0,与 0 异或结果为自身。
public class FindDuplicateNumber {
public static int findDuplicate(int[] nums) {
int result = 0;
// 先对0到1000进行异或
for (int i = 0; i <= 1000; i++) {
result ^= i;
}
// 再对数组中的元素进行异或
for (int num : nums) {
result ^= num;
}
return result;
}
public static void main(String[] args) {
int[] nums = {0, 1, 2, 3, 4, 5, 5, 6, 7, 8, 9, 1000};
int duplicate = findDuplicate(nums);
System.out.println("重复的数字是: " + duplicate); // 输出: 重复的数字是: 5
}
}
判断链表是否有公共链表部分,如果两个链表是环形链表呢?(java 代码完整实现)
对于普通链表,可以通过双指针法来判断是否有公共部分。对于环形链表,需要先判断链表是否为环形链表,再进行公共部分的判断。
class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
public class DetectCommonList {
// 判断链表是否有环
public static boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false;
}
ListNode slow = head;
ListNode fast = head.next;
while (slow!= fast) {
if (fast == null || fast.next == null) {
return false;
}
slow = slow.next;
fast = fast.next.next;
}
return true;
}
// 找到环形链表的入口
public static ListNode detectCycle(ListNode head) {
if (head == null || head.next == null) {
return null;
}
ListNode slow = head;
ListNode fast = head.next;
while (slow!= fast) {
if (fast == null || fast.next == null) {
return null;
}
slow = slow.next;
fast = fast.next.next;
}
fast = head;
while (slow.next!= fast.next) {
slow = slow.next;
fast = fast.next;
}
return slow.next;
}
// 判断两个链表是否有公共部分
public static boolean hasCommon(ListNode head1, ListNode head2) {
if (head1 == null || head2 == null) {
return false;
}
ListNode p1 = head1;
ListNode p2 = head2;
while (p1!= p2) {
p1 = p1 == null? head2 : p1.next;
p2 = p2 == null? head1 : p2.next;
}
return p1!= null;
}
public static void main(String[] args) {
// 构建链表1: 1 -> 2 -> 3 -> 4 -> 5
ListNode head1 = new ListNode(1);
head1.next = new ListNode(2);
head1.next.next = new ListNode(3);
head1.next.next.next = new ListNode(4);
head1.next.next.next.next = new ListNode(5);
// 构建链表2: 6 -> 7 -> 3 -> 4 -> 5
ListNode head2 = new ListNode(6);
head2.next = new ListNode(7);
head2.next.next = head1.next.next;
System.out.println(hasCommon(head1, head2)); // 输出: true
}
}
判断一个二叉树是否为完全二叉树,可以使用层序遍历的方式。在层序遍历过程中,若遇到一个节点没有左子节点或右子节点,那么该节点之后的所有节点都应该是叶子节点,否则就不是完全二叉树。具体实现代码如下:
import java.util.LinkedList;
import java.util.Queue;
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) {
val = x;
}
}
public class IsCompleteTree {
public static boolean isCompleteTree(TreeNode root) {
if (root == null) {
return true;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
boolean flag = false;
while (!queue.isEmpty()) {
TreeNode node = queue.poll();
if (node.left!= null) {
if (flag) {
return false;
}
queue.offer(node.left);
} else {
flag = true;
}
if (node.right!= null) {
if (flag) {
return false;
}
queue.offer(node.right);
} else {
flag = true;
}
}
return true;
}
public static void main(String[] args) {
// 构建一个简单的二叉树用于测试
TreeNode root = new TreeNode(1);
root.left = new TreeNode(2);
root.right = new TreeNode(3);
root.left.left = new TreeNode(4);
root.left.right = new TreeNode(5);
root.right.left = new TreeNode(6);
System.out.println(isCompleteTree(root));
}
}
写个堆排序代码。
堆排序是一种基于堆数据结构的排序算法,分为大顶堆和小顶堆。这里以大顶堆为例,实现升序排序。堆排序的基本步骤是先将数组构建成大顶堆,然后依次将堆顶元素与末尾元素交换,并调整堆结构,直到整个数组有序。
public class HeapSort {
public static void heapSort(int[] arr) {
int n = arr.length;
// 构建大顶堆
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 依次将堆顶元素与末尾元素交换,并调整堆结构
for (int i = n - 1; i > 0; i--) {
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
heapify(arr, i, 0);
}
}
private static void heapify(int[] arr, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
// 如果左子节点比根节点大
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
// 如果右子节点比最大节点大
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
// 如果最大节点不是根节点
if (largest!= i) {
int swap = arr[i];
arr[i] = arr[largest];
arr[largest] = swap;
// 递归调整受影响的子树
heapify(arr, n, largest);
}
}
public static void main(String[] args) {
int[] arr = {12, 11, 13, 5, 6, 7};
heapSort(arr);
for (int num : arr) {
System.out.print(num + " ");
}
}
}
分析完全二叉树和满二叉树的区别。
完全二叉树和满二叉树是二叉树的两种特殊形态,它们有以下区别:
- 定义区别:
- 满二叉树:除最后一层无任何子节点外,每一层上的所有节点都有两个子节点。也就是说,如果一个满二叉树的深度为 h,那么它的节点总数为 2^h - 1。比如深度为 3 的满二叉树,节点总数为 2^3 - 1 = 7,其形态是一个三角形,最底层的叶子节点紧密排列。
- 完全二叉树:除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若二叉树深度为 h,其节点总数 n 满足 2^(h - 1) <= n < 2^h。例如,一个深度为 3 的完全二叉树,节点数可以是 4 到 7 个,最底层的节点从左到右依次排列,右边可能存在空缺。
- 性质区别:
- 满二叉树:因为结构的完整性,在计算节点数、高度等属性时,有明确的公式。如前面提到的节点总数公式,以及高度 h = log₂(n + 1) (n 为节点总数)。而且从遍历角度,先序、中序、后序遍历的节点访问顺序有更规律的特点,因为其结构对称。
- 完全二叉树:可以用数组来高效存储,从根节点开始按层序将节点依次存储在数组中,能通过数组下标快速计算节点的父子关系。比如节点 i 的左子节点是 2i + 1,右子节点是 2i + 2,父节点是 (i - 1) / 2。这一特性在堆数据结构中广泛应用,堆通常是用完全二叉树实现的。
解释中缀表达式的概念。
中缀表达式是一种常见的算术表达式表示方法,运算符位于操作数中间。例如,我们日常书写的数学表达式 3 + 4 * 2 就是中缀表达式。它的特点是符合人类的阅读和书写习惯,易于理解。但在计算机处理时,中缀表达式的计算相对复杂。因为运算符的优先级不同,需要先确定运算顺序。比如在上述表达式中,乘法的优先级高于加法,所以要先计算 4 * 2,再加上 3。
为了让计算机能够正确计算中缀表达式,通常需要将其转换为后缀表达式(也叫逆波兰表达式)或前缀表达式(波兰表达式)。后缀表达式中,运算符紧跟在操作数之后,如上述表达式的后缀表达式为 3 4 2 * + 。计算机处理后缀表达式时,通过栈来实现计算,从左到右扫描表达式,遇到操作数就压入栈中,遇到运算符就从栈中弹出相应的操作数进行计算,然后将结果压回栈中,最终栈顶元素就是表达式的计算结果。
说明二分查找的前提。
二分查找,也叫折半查找,是一种高效的查找算法。但它有严格的前提条件:
- 数据有序:待查找的数据必须是有序的,无论是升序还是降序。只有在有序的情况下,二分查找才能通过比较中间元素与目标元素的大小,确定目标元素可能存在的区间,从而缩小查找范围。例如在升序数组 [1, 3, 5, 7, 9] 中查找 5,通过比较中间元素 7 和 5 的大小,确定 5 在 7 的左边,从而缩小查找范围到 [1, 3, 5] ,继续查找直到找到目标元素。
- 数据存储结构支持随机访问:二分查找需要快速定位到数组或其他数据结构的中间位置,所以要求数据存储结构支持随机访问。数组天然支持随机访问,通过下标可以直接访问任意位置的元素,时间复杂度为 O (1)。而链表不支持随机访问,访问链表中的元素需要从头节点开始遍历,时间复杂度为 O (n),所以链表不适合使用二分查找。
如何判断一个二叉树是否完全二叉树?
判断一个二叉树是否为完全二叉树,可以使用层序遍历的方式。在层序遍历过程中,若遇到一个节点没有左子节点或右子节点,那么该节点之后的所有节点都应该是叶子节点,否则就不是完全二叉树。具体实现代码如下:
import java.util.LinkedList;
import java.util.Queue;
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) {
val = x;
}
}
public class IsCompleteTree {
public static boolean isCompleteTree(TreeNode root) {
if (root == null) {
return true;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
boolean flag = false;
while (!queue.isEmpty()) {
TreeNode node = queue.poll();
if (node.left!= null) {
if (flag) {
return false;
}
queue.offer(node.left);
} else {
flag = true;
}
if (node.right!= null) {
if (flag) {
return false;
}
queue.offer(node.right);
} else {
flag = true;
}
}
return true;
}
public static void main(String[] args) {
// 构建一个简单的二叉树用于测试
TreeNode root = new TreeNode(1);
root.left = new TreeNode(2);
root.right = new TreeNode(3);
root.left.left = new TreeNode(4);
root.left.right = new TreeNode(5);
root.right.left = new TreeNode(6);
System.out.println(isCompleteTree(root));
}
}
写个堆排序代码。
堆排序是一种基于堆数据结构的排序算法,分为大顶堆和小顶堆。这里以大顶堆为例,实现升序排序。堆排序的基本步骤是先将数组构建成大顶堆,然后依次将堆顶元素与末尾元素交换,并调整堆结构,直到整个数组有序。
public class HeapSort {
public static void heapSort(int[] arr) {
int n = arr.length;
// 构建大顶堆
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 依次将堆顶元素与末尾元素交换,并调整堆结构
for (int i = n - 1; i > 0; i--) {
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
heapify(arr, i, 0);
}
}
private static void heapify(int[] arr, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
// 如果左子节点比根节点大
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
// 如果右子节点比最大节点大
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
// 如果最大节点不是根节点
if (largest!= i) {
int swap = arr[i];
arr[i] = arr[largest];
arr[largest] = swap;
// 递归调整受影响的子树
heapify(arr, n, largest);
}
}
public static void main(String[] args) {
int[] arr = {12, 11, 13, 5, 6, 7};
heapSort(arr);
for (int num : arr) {
System.out.print(num + " ");
}
}
}
分析完全二叉树和满二叉树的区别。
完全二叉树和满二叉树是二叉树的两种特殊形态,它们有以下区别:
- 定义区别:
- 满二叉树:除最后一层无任何子节点外,每一层上的所有节点都有两个子节点。也就是说,如果一个满二叉树的深度为 h,那么它的节点总数为 2^h - 1。比如深度为 3 的满二叉树,节点总数为 2^3 - 1 = 7,其形态是一个三角形,最底层的叶子节点紧密排列。
- 完全二叉树:除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若二叉树深度为 h,其节点总数 n 满足 2^(h - 1) <= n < 2^h。例如,一个深度为 3 的完全二叉树,节点数可以是 4 到 7 个,最底层的节点从左到右依次排列,右边可能存在空缺。
- 性质区别:
- 满二叉树:因为结构的完整性,在计算节点数、高度等属性时,有明确的公式。如前面提到的节点总数公式,以及高度 h = log₂(n + 1) (n 为节点总数)。而且从遍历角度,先序、中序、后序遍历的节点访问顺序有更规律的特点,因为其结构对称。
- 完全二叉树:可以用数组来高效存储,从根节点开始按层序将节点依次存储在数组中,能通过数组下标快速计算节点的父子关系。比如节点 i 的左子节点是 2i + 1,右子节点是 2i + 2,父节点是 (i - 1) / 2。这一特性在堆数据结构中广泛应用,堆通常是用完全二叉树实现的。
解释中缀表达式的概念。
中缀表达式是一种常见的算术表达式表示方法,运算符位于操作数中间。例如,我们日常书写的数学表达式 3 + 4 * 2 就是中缀表达式。它的特点是符合人类的阅读和书写习惯,易于理解。但在计算机处理时,中缀表达式的计算相对复杂。因为运算符的优先级不同,需要先确定运算顺序。比如在上述表达式中,乘法的优先级高于加法,所以要先计算 4 * 2,再加上 3。
为了让计算机能够正确计算中缀表达式,通常需要将其转换为后缀表达式(也叫逆波兰表达式)或前缀表达式(波兰表达式)。后缀表达式中,运算符紧跟在操作数之后,如上述表达式的后缀表达式为 3 4 2 * + 。计算机处理后缀表达式时,通过栈来实现计算,从左到右扫描表达式,遇到操作数就压入栈中,遇到运算符就从栈中弹出相应的操作数进行计算,然后将结果压回栈中,最终栈顶元素就是表达式的计算结果。
说明二分查找的前提。
二分查找,也叫折半查找,是一种高效的查找算法。但它有严格的前提条件:
- 数据有序:待查找的数据必须是有序的,无论是升序还是降序。只有在有序的情况下,二分查找才能通过比较中间元素与目标元素的大小,确定目标元素可能存在的区间,从而缩小查找范围。例如在升序数组 [1, 3, 5, 7, 9] 中查找 5,通过比较中间元素 7 和 5 的大小,确定 5 在 7 的左边,从而缩小查找范围到 [1, 3, 5] ,继续查找直到找到目标元素。
- 数据存储结构支持随机访问:二分查找需要快速定位到数组或其他数据结构的中间位置,所以要求数据存储结构支持随机访问。数组天然支持随机访问,通过下标可以直接访问任意位置的元素,时间复杂度为 O (1)。而链表不支持随机访问,访问链表中的元素需要从头节点开始遍历,时间复杂度为 O (n),所以链表不适合使用二分查找。