多线程之八股文🙂

一、锁策略
加黄部分就是 synchronized锁 的性质
1、乐观锁vs悲观锁
悲观锁:先锁后操作,适合高冲突场景
乐观锁:先操作后检查,适合低冲突场景
2、重量级锁 vs 轻量级锁
重量级锁是"挂起等待"(让出CPU,省资源但慢),轻量级锁是"自旋"(占着CPU死等,费资源但快)
重量级锁 = 挂起等待,轻量级锁 = 自旋
两组概念讲的是同一件事,只是角度不同:一组强调开销,一组强调行为。
3、挂起等待锁 vs 自旋锁
挂起等待是"睡觉等",不占CPU但响应慢;自旋是"站着等",占CPU但响应快
4、公平锁 vs 非公平锁
公平锁 = 排队追女神,先来后到
非公平锁 = 半路截胡,谁抢到是谁的
公平锁严格按等待顺序获取锁(先来后到)
非公平锁允许插队(后请求的线程可能先获得锁)
5、可重入锁 vs 不可重入锁
可重入锁同一线程能反复拿锁,不可重入锁会自己锁自己
6、普通互斥锁 vs 读写锁
互斥锁读写都互斥,读写锁读不互斥、写才互斥
二、JVM 的优化机制
这三个概念是 JVM 对
synchronized的优化机制,目的是在不影响正确性的前提下,减少锁带来的性能开销
锁升级
锁只能升级不能降级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁
锁升级:竞争越激烈,锁越重
锁消除:单线程用的锁,直接去掉
锁粗化:反复开关太费事,合并成一次
三、CAS操作
CAS 是 CPU 级别的原子操作,比较并交换,成功就改,失败就重试
优点:
不挂起线程(指让一个正在运行的线程主动或被动地放弃 CPU 使用权,进入"等待"状态)、
快
缺点:
ABA 问题、自旋耗 CPU
一个值从 A 变成 B 又变回 A,CAS 会认为没变过,解决方法是加版本号
四、Runnable 与 Callable
"Runnable 和 Callable 都是任务接口,主要区别有两点:
返回值:Callable 有泛型返回值,Runnable 是 void;
异常:Callable 可以抛出受检异常,Runnable 只能在内部处理。
FutureTask 是两者的桥梁,因为它既实现了 Runnable(可被 Thread 或线程池执行),又实现了 Future(可保存结果)
使用时,把 Callable 包装进 FutureTask,交给线程执行,最后调用 futureTask.get() 获取结果
五、synchronized 与 ReentrantLock
synchronized 是 JVM 层面的关键字,用法简单,自动加锁解锁,但只能非公平、死等
ReentrantLock 是 Java 类库提供的,需要手动 lock/unlock,优点是支持 tryLock(可以超时或立即返回),支持公平锁,还能用 Condition 实现多个等待队列(比如"生产者-消费者场景用两个 Condition 分别控制满和空")
ReentrantLock 不会自动释放锁,必须手动调用 unlock(),而 finally 能保证无论是否发生异常,锁都能被释放
总结:两者都是可重入锁
简单场景用 synchronized,需要公平锁或超时等待时用 ReentrantLock
六、Semaphore
Semaphore 是信号量,用于控制同时访问资源的线程数。它的 acquire() 在拿不到许可时会让线程阻塞(挂起),release() 用于归还许可并唤醒等待线程。两者与 ReentrantLock 的 lock()/unlock() 类似,都是基于 AQS 实现的。
和 synchronized 不同的是,Semaphore 没有 JVM 层面的偏向锁、自旋锁优化,它是直接通过 AQS 进行阻塞唤醒。
不过在实际开发中,大部分简单同步场景用 synchronized 就够了,代码简洁且 JVM 会自动优化。除非需要限流(比如数据库连接池控制并发数),才会用 Semaphore
七、CountDownLatch
"CountDownLatch 是 JUC 包下的同步工具,让一个或多个线程等待,直到一组操作完成。
核心方法是 await() 和 countDown():
await():线程在这里挂起(阻塞),直到计数器归零
countDown():计数器减 1,归零时唤醒所有等待的线程
常见用法有两个:
主线程等待子任务 :主线程 await(),每个子线程做完调用 countDown()
多个线程同时启动 :所有线程先 await(),由另一个线程一次性 countDown() 触发
和 join() 的区别 :join() 只能等一个线程死掉,CountDownLatch 可以等一组操作(不一定是线程结束,只要调了 countDown() 就行)
八、Hashtable 和 ConcurrentHashMap
Hashtable(铺垫痛点):
Hashtable 是一把大锁锁整个 Map,用 synchronized 修饰方法,同一时间只有一个线程操作,性能很差
ConcurrentHashMap 的并发优化:
给每个链表都安排一把锁 ------ 不同链表的操作互不影响,可以并发执行。JDK 1.7 是分段锁(16个 Segment,相当于 16 把锁),JDK 1.8 之后进一步细化为锁单个桶(链表头或红黑树根),并发度更高。
size 使用 CAS 进行更新 ------ 不需要加锁就能原子更新计数器,减少锁竞争。
扩容化整为零 ------ 不是一次性迁移所有数据,而是把整个扩容任务拆分成多个小任务,每次迁移一部分,允许其他线程边读边写边帮忙扩容,避免卡顿。
九、文件三面试题
1、遍历目录(基础版)
java
package javaee;
import java.io.File;
//基础版遍历目录
public class Demo23 {
public static void main(String[] args) {
//绝对路径
File dir = new File("E:/my-first-web");
//先判断是不是目录
if(!dir.isDirectory()){
System.out.println("目录不存在或不是文件夹:" + dir.getAbsolutePath());
return;
}
//1、当前目录下所有文件和目录的名字
String[] names = dir.list();
System.out.println("=====所有文件和目录名字=====");
for(String name : names){
System.out.println(name);
}
//2、当前目录下所有文件和目录的 File 对象
File[] files = dir.listFiles();
System.out.println("=====所有对象=====");
for (File file : files){
if(file.isFile()){
System.out.println("[文件]" + file.getName());
}
if (file.isDirectory()){
System.out.println("[目录]" + file.getName());
}
}
}
}
这是一个基础版本的文件遍历,若想更装杯,可以使用递归来写😎
2、复制文件
java
package javaee;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Scanner;
public class Demo24 {
public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in);
System.out.print("请输入要复制的文件(绝对路径 OR 相对路径):");
String sourcePath = scanner.next();
//创建原文件对象
File sourceFile = new File(sourcePath);
if (!sourceFile.exists()) {
System.out.println("文件不存在,请确认路径是否正确");
return;
}
if (!sourceFile.isFile()) {
System.out.println("文件不是普通文件,请确认路径是否正确");
return;
}
System.out.print("请输入要复制的目标路径(绝对路径 OR 相对路径):");
String destPath = scanner.next();
//创建复制完成的文件对象
File destFile = new File(destPath);
if (destFile.exists()) {
if (destFile.isDirectory()) {
System.out.println("目标路径已经存在,并且是一个目录,请确认路径是否正确");
return;
}
if (destFile.isFile()) {
System.out.print("目标文件已经存在,是否要进行覆盖?(y/n):");
String ans = scanner.next();
if (ans.equals("y")) {
System.out.println("开始覆盖复制...");
} else {
System.out.println("停止复制");
return;
}
}
}
// ========== 真正的复制代码 ==========
try (FileInputStream fis = new FileInputStream(sourceFile);
FileOutputStream fos = new FileOutputStream(destFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = fis.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
}
System.out.println("复制完成!目标文件位置:" + destFile.getAbsolutePath());
}
}
3、复制整个目录
java
package javaee;
import java.io.*;
import java.util.*;
public class Demo25 {
public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in);
System.out.print("请输入要复制的源目录:");
String srcPath = scanner.next();
File srcDir = new File(srcPath);
if (!srcDir.isDirectory()) {
System.out.println("源目录不存在或不是文件夹");
return;
}
System.out.print("请输入目标目录:");
String destPath = scanner.next();
File destDir = new File(destPath);
// 调用复制目录的方法(遍历 + 复制)
copyDirectory(srcDir, destDir);
System.out.println("目录复制完成!");
}
// 复制目录(结合了遍历 + 复制)
public static void copyDirectory(File srcDir, File destDir) throws IOException {
// 1. 创建目标目录(遍历的第一步)
if (!destDir.exists()) {
destDir.mkdirs();
}
// 2. 遍历源目录(来自 Demo23 的思想)
File[] children = srcDir.listFiles();
if (children != null) {
for (File child : children) {
File destChild = new File(destDir, child.getName());
if (child.isFile()) {
// 3. 复制文件(来自 Demo24 的核心代码)
copyFile(child, destChild);
} else if (child.isDirectory()) {
// 4. 遇到子目录,递归调用自己(继续遍历 + 复制)
copyDirectory(child, destChild);
}
}
}
}
// 复制单个文件(来自 Demo24 的核心代码)
private static void copyFile(File src, File dest) throws IOException {
try (FileInputStream fis = new FileInputStream(src);
FileOutputStream fos = new FileOutputStream(dest)) {
byte[] buffer = new byte[8192];
int len;
while ((len = fis.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
}
System.out.println("复制文件:" + src.getName());
}
}
相当于前两者的结合
十、网络基本盘
1、OSI 七层 / TCP/IP 四层
网络一般看 TCP/IP 四层模型 :应用层、传输层、网络层、网络接口层。数据发送时从上往下封装 (每层加报头),接收时从下往上分用(每层拆报头)
2、Socket编程
Socket 是应用层与传输层之间的"桥梁",让你不用关心下面两层(网络层、网络接口层)的细节
| #### TCP/IP 四层 | #### 负责什么 | #### Socket 的角色 |
|---|---|---|
| #### 应用层 | #### 你的程序(浏览器、微信等) | #### 你写代码的地方 |
| #### ↑↑↑↑↑↑↑↑↑↑↑↑↑ | #### Socket 接口在这里 | #### ↑↑↑↑↑↑↑↑↑↑↑↑↑ |
| #### 传输层 | #### TCP/UDP(端口) | #### Socket 帮你调用 |
| #### 网络层 | #### IP(路由) | #### Socket 帮你调用 |
| #### 网络接口层 | #### MAC、网卡(物理传输) | #### Socket 帮你调用 |
3、TCP 和 UDP 有什么区别?
主要有三点区别:
连接:TCP 有连接,通信前要先保存对方的 IP 和端口(三次握手);UDP 无连接,不保存对方信息,直接发
可靠性:TCP 可靠传输(牺牲效率),有确认和重传;UDP 不可靠,丢了不管
数据方式:TCP 面向字节流,没有边界;UDP 面向数据报,有边界
另外两者都是全双工(可以同时收发,即同一时刻双向通信)
4、TCP 和 UDP 在"数据传输方式"上的解释
TCP(面向字节流)
想发送 100 个字节,可以 1 次全发,也可以分 10 次每次发 10 字节,还可以分 100 次每次发 1 字节
本质: TCP 不关心你分几次发,只关心你发了多少字节。接收方也按字节流读,不知道你原来分了几次
类比: 像水管里的水,你倒一桶水进去,出来也是一桶水,但中间是不分"块"的
UDP(面向数据报)
一次必须是发送/接收一个完整的 UDP 数据报,不能是半个
本质: UDP 有边界,发的时候是一个完整的包,收的时候也必须一个完整的包收
类比: 像寄快递,你发一个包裹,对方收到一个包裹,不会收到半个
5、TCP和UDP的Socket实现区别
代码实现对比
| #### 维度 | #### TCP Socket | #### UDP Socket |
|---|---|---|
| #### 核心类 | #### ServerSocket / Socket |
#### DatagramSocket |
| #### 数据传输 | #### 基于流(Stream),数据无边界 | #### 基于数据报(Datagram),每个包独立 |
| #### 连接方式 | #### 需要 accept() 建立连接(阻塞) |
#### 无需连接,直接发 |
| #### 收发方法 | #### getInputStream() / getOutputStream() |
#### send(DatagramPacket) / receive(DatagramPacket) |
| #### 丢包处理 | #### 底层自动重传,保证到达 | #### 不保证,可能丢包/乱序 |
| #### 服务端标识 | #### 每个客户端有独立Socket | #### 只有一个DatagramSocket,靠包内地址区分 |
追问1:那TCP服务端怎么同时处理多个客户端?
①多线程
优点:简单直观,每个客户端逻辑独立
缺点:
-
1个客户端 = 1个线程,1000个客户端 = 1000个线程
-
线程切换开销大,内存占用高(每个线程约1MB栈空间)
-
C10K问题(1万并发就扛不住了)
再问:线程池能解决吗?
能缓解,但不能根治。线程池限制了最大线程数,超出就得排队或拒绝,本质还是"一个连接一个线程"的模式
②IO多路复用
优点:
-
一个线程能管理成千上万个连接
-
没有线程切换开销
-
内存占用低
一个线程同时监控多个socket,哪个有数据就读哪个
追问2:epoll的ET vs LT?(边缘触发 vs 水平触发)
| #### 模式 | #### 一句话解释 | #### 行为 |
|---|---|---|
| #### LT(水平触发) | #### 有数据就一直通知你 | #### 缓冲区有数据,每次调用epoll_wait都会通知 |
| #### ET(边缘触发) | #### 新数据来了只通知一次 | #### 只在状态从"无数据→有数据"时通知一次 |
回答:
LT是默认模式,更安全,但可能重复通知;ET性能更高,但要求用户一次性把数据读完,否则数据会丢。Redis、Nginx用ET追求极致性能
本章完
