04、JAVAEE---多线程进阶、文件I/O、网络初识

多线程之八股文🙂


一、锁策略

加黄部分就是 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() 用于归还许可并唤醒等待线程。两者与 ReentrantLocklock()/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 有什么区别?

主要有三点区别:

  1. 连接:TCP 有连接,通信前要先保存对方的 IP 和端口(三次握手);UDP 无连接,不保存对方信息,直接发

  2. 可靠性:TCP 可靠传输(牺牲效率),有确认和重传;UDP 不可靠,丢了不管

  3. 数据方式: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追求极致性能


本章完

相关推荐
zyl837211 小时前
Java 后端完整技术栈
java·开发语言
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第107题】【并发篇】第7题:说说 Lock 锁?
java·开发语言·面试
杨了个杨89821 小时前
Dockerfile介绍及镜像制作
java·开发语言
c++之路1 小时前
CMake 系列教程(三):变量、条件与控制流
java·windows·spring
一条泥憨鱼2 小时前
苍穹外卖【day5|Redis与店铺营业状态设置】
java·后端·mybatis·苍穹外卖
要开心吖ZSH2 小时前
AI医疗分诊与健康咨询助手agent开发——(2)让AI输出可控:结构化分诊与安全规则
java·ai·agent·健康医疗·spring ai
San813_LDD4 小时前
[C语言]《Dev-C++ 报错解决手册(Day0607 精华版)》
java·前端·javascript
Anastasiozzzz5 小时前
从有限状态机到智能体图:传统 FSM 与 Agent Graph的演进
java·人工智能·python·ai