记一些常见的 Java 面试问题
语言本身
JDK 和 JRE 有什么区别?
- JDK 是整个 Java 的核心,包括 Java 运行环境(Java Runtime
Envirnment,简称 JRE),Java 工具(比如 javac、java、javap 等等),以及
Java 基础类库(比如 rt.jar);
- rt.jar 是 Java 的基础类库,包含 lang, util, math, time, awt, applet等基础包
- JRE 是 Java 程序的运行环境;
- JDK 包含了同版本的 JRE ,除此之外还有编译器和其他工具。
C++ 的析构函数为什么要设计成虚函数?
我也不知道我面的是 Java 岗为什么要问我 C++
- 如果一个类的析构函数是虚函数,那么由它派生而来的所有子类的析构函数也是虚函数。析构函数设置为虚函数之后,在使用指针引用时可以动态绑定,实现运行时的多态,保证使用基类类型的指针就能够调用适当的析构函数针对不同的对象进行清理工作
- 当使用多态特性,让基类指针指向派生类对象时,如果析构函数不是虚函数,通过基类指针销毁派生类对象时,会调用静态绑定的析构函数,也就是基类的析构函数,从而只能销毁属于基类的元素,导致派生类析构不完全,程序就会出现资源泄露或未定义行为。
- 当派生类中不存在使用动态资源或其他自定义析构行为时,可以不写为虚析构函数,来提高程序效率。但为了程序的可扩展性和健壮性,在使用多态特性时,一般都建议将基类的析构函数定义为虚函数。
关于并发编程
volatile 关键字有什么作用?能够保证线程安全吗?
- 在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
- volatile 关键字其实并非是 Java 语言特有的,在 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。如果我们将一个变量使用 volatile 修饰,这就指示编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
- volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
- volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
关于对象
Object 类
Object 类是一个特殊的类,是所有类的父类。它包含十一个方法:
- getclass:获取 Class 对象
- hashcode:获取 hashcode 值,配合散列容器使用,包括 HashMap、hashTable、HashSet等
- equals
- clone:返回一个 clone 的对象
- toString
- notify:唤醒一个在此对象监视器上等待的线程
- notifyall:唤醒所有在此对象监视器上等待的线程
- wait:暂停线程执行,并且释放锁
- finalize:垃圾回收时触发,一般不用
Unsafe 类
Unsafe 用于执行一些低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等。
Unsafe 类中的方法都是依赖于本地方法实现的,Java 只是声明头方法,具体实现交给本地代码。
出于安全性考虑,Unsafe
类只能由启动类加载器加载的类才能够调用其方法,可以借助反射来获取一个实例化完成的单例对象
theUnsafe
1
2
3
4
5
6
7
8
9
10private static Unsafe reflectGetUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
log.error(e.getMessage(), e);
return null;
}
}
关于容器
Hashmap 相关
Hashmap 结构
- JDK 1.7 之前是数组 + 链表
- JDK 1.8 及之后是 数组 + 链表/红黑树
- 当数组大小 < 64 时,通过扩容来保证稀疏,加载因子默认0.75,每次扩容翻倍,初始容量 16
- 当数组大小 >= 64 且链表长度 > 8 时,转为红黑树,链表长度 <= 6时退化回链表
Hashmap 是线程安全的吗?
Hashmap 并不是线程安全的,在并发环境下,Hashmap 可能会出现以下问题:
- 数据覆盖
- 多线程对同一个 Hashmap 进行操作时,计算出来的索引相同可能会导致后面的线程把前一个线程数据覆盖
- 读出为 null
- 当线程 1 进行 put 时发现需要扩容,需要 new 一个新的表,线程 2 此时进行 get 就可能会读到空值
- 死循环
- JDK 1.7 中, Hashmap 的扩容使用的是头插法,可能会出现环形链表,当请求一个不存在的 key 时产生死循环。
- JDK 1.8及之后采用的是尾插法,不会导致死循环。
如何保证 Hashmap 线程安全呢?
Java 提供了三种保证线程安全的哈希方法,分别是 HashTable 、Collections.synchronizedMap 以及 ConcurrentHashMap。
- HashTable:通过给每一个方法加锁 (synchronized) 来保证互斥,效果比较差,一般不推荐使用。
- Collections.synchronizedMap:通过 Collection 对已有的一个 Hashmap
进行包装,内部使用一个 mutex 对象,使用 synchronized
对其加锁保证互斥,和 HashTable 原理差不多,性能也比较差。
Map<String, Object> map = Collections.synchronizedMap(new HashMap<String, Object>());
- ConcurrentHashMap:推荐使用
ConcurrentHashMap如何保证线程安全呢?
- JDK 1.7及其之前是 Segment数组 + HashEntry数组 + 链表,其中 Segment
继承了 ReentrantLock,相当于对每个 Segment 加锁,每个 Segment 类似一个
Hashmap 结构
- 扩容每次翻倍,但元素的新位置要么和之前相同,要么为
原idx + 原容量
- CAS 初始化 Segment
- 扩容每次翻倍,但元素的新位置要么和之前相同,要么为
- JDK 1.8及其之后是 Node 数组 + 链表/红黑树
- put 时定位到目标位置,如果为空 CAS 写入,反之 synchronized 写入
- 多线程扩容,put 时发现正在扩容,就和它一起扩容
一些工具
如何检查内存泄漏问题
在 java 的 bin 文件夹下有个 jvisualvm.exe 工具,使用它可以检测到 java 堆内存的变化情况,借此可以来检测使用 java 的程序是否存在内存泄漏问题。
如何检查死锁问题
可以使用 Java 的 jstack 工具,它是 jdk 自带的线程堆栈分析工具。查看线程的函数调用过程,多次对比结果,确认哪几个线程一直没有变化,且是因为在等待锁,那么大概率是由于死锁问题导致的。
关于操作系统
进程与线程
线程的类型
- 用户线程
- 基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB
- 内核线程
- 由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。
- 轻量级进程
- 内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度。
进程上下文切换保存了什么?
在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。
PCB 主要包含以下信息:
- 进程描述信息:进程标识符标识进程,用户标识符标识进程所属用户
- 进程控制和管理信息:当前进程的状态(五态),进程的优先级
- 资源清单:有关的内存地址空间或虚拟地址空间信息,打开的文件和使用的 I/O 设备信息
- CPU相关:各个寄存器的值
当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中。
进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
每个 PCB 根据其对应进程的不同状态,通过链表的形式组织在一起,分别为就绪队列、阻塞队列等。
线程上下文切换保存了什么?
当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的;
除此之外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。
线程的上下文切换分两种情况:
- 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
- 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;
Java 线程和底层操作系统线程是一一对应的吗?
首先,在 JDK 1.2 之前,Java 线程是用户线程实现的;JDK 1.2 及其之后,线程模型变为基于操作系统原生线程模型来实现。
现在的JDK线程模型基于操作系统原生线程,所以模型依赖于操作系统对线程的支持,另外 Windows 和 Linux 系统提供的线程模型就是一对一的。
Linux 中如何查看线程相关信息?
- top -H
- 加上 -H 这个选项,top 的每一行就不是显示一个进程,而是一个线程
- ps -XH
- 可以查看所有存在的线程,也可以使用 grep 作进一步的过滤
- ps -mq PID
- 可以看到指定的进程产生的线程数目
- cat /proc/{pid}/status
- pstree -p
- 打印所有进程及其线程
基于操作系统讲一下堆和栈?
一开始听错了,讲了半天什么是栈结构,什么是堆结构,优先队列都整出来了。
后来连忙重新组织了一下,乱讲一通堆区和栈区的区别。
一个由 C/C++ 编译的程序占用的内存分为以下几个部分:
- 栈区(stack)—— 由操作系统自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈;
- 堆区(heap)—— 一般由程序员分配释放,若程序员不释放,程序结束时可能由 OS 回收。分配方式倒是类似于链表;
- 全局区(静态区)(static)—— 全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束后由系统释放;
- 文字常量区 —— 常量字符串就是放在这里的。程序结束后由系统释放;
- 程序代码区 —— 存放函数体的二进制代码。
操作系统中有什么具体的区别?
- 栈由高地址向低地址扩展,堆是由低地址向高地址扩展;
- 用户用 new 或者 malloc 申请的空间都放在堆区,一些局部变量之类的放在栈帧内;
- 堆有很多种空间分配方法(最佳适应算法、最坏适应算法、首次适应算法、循环首次适应算法、),栈没有这么多,硬分配,空间不够就报错;
- 栈申请效率高,堆相比较有点慢
关于数据结构
为什么快速排序是 O(nlogn) ?是归并思想还是 dp 思想?
其实快排并不是严格的 \(O(nlogn)\),它的时间复杂度在 \(O(nlogn) - O(n^2)\) 之间
- 最坏的情况下,每次哨兵都只能将长度为 \(n\) 的数组分成 \(0\) 和 \(n-1\) 两部分,这样的话递归栈的深度就是 \(O(n)\) ,退化到 \(O(n^2)\)
- 最好的情况下,每次哨兵将数组平分,这样的递归栈深度就是 \(O(logn)\) ,时间复杂度为 \(O(nlogn)\)
- 其他情况就在这俩中间
那问题来了是归并还是 dp 呢?
其实我觉得这个问题很奇怪,因为归并和 DP 不是一个维度的问题,你可以说是贪心还是 DP?或者说是不是归并思想?事实上,快排的本质和归并排序思路差不多,可以看作是归并,只是一个自顶向下一个自底向上。
关于计算机网络
HTTPS 会加密 URL 吗?
因为 URL 的信息都是保存在 HTTP Header 中的,而 HTTPS 是会对 HTTP Header + HTTP Body 整个加密的,所以 URL 自然是会被加密的。
HTTP/1.1 使用明文的形式在请求的第一行注明 URL,HTTP/2 使用伪头部 (pseudo-header) 替换了请求行,伪头部一般在名称的开头使用冒号表示。
但是 HTTPS 可以在 TLS 握手阶段看到域名信息,无法实现对域名的隐藏。
关于数据库
MySQL
杂项
- MySQL 中 InnoDB 页的大小默认是 16k
MySQL为什么采用 B+ 树?
作者:我啊 链接:https://leetcode.cn/circle/discuss/uRUsKF/ 来源:力扣(LeetCode) 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
哈希范围查询很慢。链表要遍历。剩下的就是树。广为人知的,二叉搜索树,AVL 树,红黑树,B 树等等。
- 二叉搜索树会退化为链表,层数可能也会很多
- AVL树层数依然过多
- 红黑树只是优化了插入、更新,弱化了平衡,在更新和搜索中取了折中。但层数过多的问题没有解决
- B 树,层数变少了,但如果访问下一页需要回到父节点到兄弟节点
- B+ 树,将叶子节点用链表串联起来了,子节点中包含了父节点的信息——解决 B 树访问下一页需要先回到父节点的问题;同时非叶子节点不保存具体的数据,而只保存关键字的索引,具体数据保存在叶子结点中——相对于 B 树,减少了非叶子结点(索引)的数据量,所以相同的内存空间能保存更多的索引,从而减少 io 次数。
事务的实现
作者:我啊 链接:https://leetcode.cn/circle/discuss/uRUsKF/ 来源:力扣(LeetCode) 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
事务是作为单个逻辑工作单元执行的一系列操作。一个逻辑工作单元必须有四个属性,称为原子性、一致性、隔离性和持久性 (ACID) 属性,只有这样才能成为一个事务。事务一般都是与数据库打交道的操作。
事务的 ACID 特性是由关系数据库管理系统来实现的。
DBMS 采用日志来保证事务的原子性、一致性和持久性。MySQL 通过预写式日志 undo log、redo log,如果整个事务执行的过程系统崩溃或者断电了,在系统重启的时候,恢复机制会将 undo log中未提交的事务进行回滚,保证事务的原子性;而 redo log 中已提交的事务重做,保证事务的持久性。
DBMS 采用锁机制来实现事务的隔离性。当多个事务同时更新数据库中相同的数据时,只允许持有锁的事务能更新该数据,其他事务必须等待,直到前一个事务释放了锁,其他事务才有机会更新该数据。
MySQL 主从复制
作者:我啊 链接:https://leetcode.cn/circle/discuss/uRUsKF/ 来源:力扣(LeetCode) 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
主从复制是指将主数据库(Master)中的 DDL 和 DML 操作通过二进制日志传输到从数据库(Slave) 上,然后将这些日志重新执行(重做),从而使得从数据库的数据与主数据库保持一致。MySQL 支持单向、异步复制,复制过程中一个服务器充当主服务器,而一个或多个其它服务器充当从服务器。
作用
- 当主数据库出现问题时,可以切换到从数据库;
- 可以进行数据库层面的读写分离,实现负载均衡;
- 可以在从数据库上进行实时数据备份。
基本原理
MySQL 的主从复制是一个异步的复制过程(一般情况下感觉是实时的),数据将从一个 MySQL 数据库(Master)复制到另外一个 MySQL 数据库(Slave),在 Master 与 Slave 之间实现整个主从复制的过程是由三个线程参与完成的,其中有两个线程(SQL 线程和 I/O 线程)在 Slave 端,另外一个线程( I/O 线程)在 Master 端。
- Master 端:打开二进制日志(binlog )记录功能 —— 记录下所有改变了数据库数据的语句,放进 Master 的 binlog 中;
- Slave 端:开启一个 I/O 线程 —— 负责从 Master上拉取 binlog 内容,放进自己的中继日志(Relay log)中;
- Slave 端:SQL 执行线程 —— 读取 Relay log,并顺序执行该日志中的 SQL 事件。
三种模式
- 同步复制:所有从机接收到
binlog,才认为事务提交成功;
- 最安全,但性能差,一般不用
- 异步复制:只要主机事务提交成功,就对客户端返回成功;后台线程异步把
binlog 同步给从机,然后从机回放;
- 最快,但可能丢失数据;
- 半同步复制:部分从机接收到 binlog,才对客户端返回成功
一般做法是牺牲一致性换取高可用;数据丢失后,人工修复。 为了解决主从复制延迟太大,切换到从机后数据丢失太多的问题,采用了并行回放策略。
- 按数据维度并行:两个事务没有操作交集可以并行;
- 按事务提交顺序并行:同时提交成功的事务是可以并行的)
关于设计题
设计一个短链服务
短链服务总的来说,就做两件事:
- 将长链接变为短链接,当然是越短越好
- 用户点击短链接的时候,实现自动跳转到原来的长链接
长链如何转短链?
方法1:hash
对长链进行 hash ,常见的 hash 算法有:
- MurmurHash 算法:非加密型哈希函数,Redis,Memcached,Cassandra,HBase,Lucene 都在用这种算法
但是会出现一个问题:hash 碰撞怎么办?
- 加点特殊字符,直到没有碰撞
方法2:统一发号保证全局唯一
有以下几种解决思路:
- Redis 自增:Redis性能好,单机就能支撑10W+请求,如果作为发号器,需要考虑Redis持久化和灾备。
- MySQL 自增主键:这种方案和Redis的方案类似,是利用数据库自增主键的提醒实现,保证ID不重复且连续自动创建。
- Snowflake:这是一种目前应用比较广的ID序列生成算法,美团的Leaf是对这种算法的封装升级服务。但是这个算法依赖于服务器时钟,如果有时钟回拨,可能会有ID冲突。
- 生成全局唯一 ID 的长度为 64 位
- 最高 1 位固定值 0,因为生成的 id 是正整数,如果是 1 就是负数了。
- 接下来 41 位存储毫秒级时间戳,2^41/(1000606024365)=69,大概可以使用 69 年。
- 再接下 10 位存储机器码,包括 5 位 datacenterId 和 5 位 workerId。最多可以部署 2^10 = 1024 台机器。
- 最后 12 位存储序列号。同一毫秒时间戳时,通过这个递增的序列号来区分。即对于同一台机器而言,同一毫秒时间戳下,可以生成 2^12=4096 个不重复 id。
- 。。。
出问题了,直接将自增的 ID 放上去当短链会不会不安全?怎么保证安全呢?
- 可以把数字换成字母,a-z,A-Z,混淆一下
但又好像需要考虑一个问题,对于同一个原始链接,应该返回相同的短链还是不同的短链?
- 可以根据其他信息(用户信息、位置信息)等来判断是不是相同的生成请求
短链怎么存呢?
可以直接扔到 MySQL 或者 NoSQL 里,然后接受请求,解析短链并转发
当然考虑到并发量的话,分布式、分库分表什么的就可以都上来搞一下
业务逻辑呢?
主要逻辑如下:
- 用户使用短链请求
- 服务器根据短链找到对应的长链
- 进行请求的转发
- 结束调用
有没有什么优化呢?数据库被请求打穿了怎么办?
上布隆过滤器,上缓存,。。。。
关于智力题
一天 24 小时,时针和分针重合多少次?
其实这个题有个很简单的解法,一天 24 个小时分针转 24 圈,时针转两圈,所以分针要超过时针的圈数是 24 - 2 = 22 圈。
当然也可以硬算,分针每分钟转 1 格,时针每分钟转 1/12 格,因此每次相遇要花费的时间是 \(\frac{60}{1 - \frac{1}{12}} = \frac{720}{11}\) 分钟,一天共有 24 * 60 = 1440 分钟,直接相除可以得到 22 圈。