12.9八股面经案例学习
如何保证线程安全?
解答
Java 保证线程安全主要依靠三类机制:互斥 (如 synchronized、ReentrantLock 保证同一时刻只有一个线程进入临界区)、可见性与有序性 (如 volatile 禁止指令重排、确保修改对其他线程立即可见)、以及 原子性(如 Atomic 原子类基于 CAS 无锁更新避免竞争)。这些构成 Java 并发的底层基础。
实际开发中一般通过 并发安全的容器和工具类 (如 ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue)、线程封闭与不可变对象 、以及 线程池(Executor 框架) 来构建整体线程安全的体系。在读多写少场景常用 ReentrantReadWriteLock,在任务协作使用 CountDownLatch、Semaphore 等,从语言、容器到框架层层保证程序在多线程环境下运行正确且高效。
详解
互斥
句子 :互斥(如 synchronized、ReentrantLock 保证同一时刻只有一个线程进入临界区)
-
意义:互斥是为了解决竞态条件(两个或更多线程同时修改共享状态导致结果不确定)。互斥保证在临界区的执行有序、互相排斥。
-
synchronized-
作用:取得对象或类的监视器(monitor),进入临界区;退出时释放锁。
-
特性:可重入(同一线程可重复获得),由 JVM 管理,自动释放(异常也会释放)。
-
例子:
javapublic synchronized void add(int x) { this.count += x; }
-
-
ReentrantLock-
作用:java.util.concurrent.locks 下的显式锁。
-
优点:可中断锁获取(lockInterruptibly)、可尝试获取(tryLock)、可选择公平策略、能结合条件变量(Condition)。
-
要点:要手动 unlock,通常放在 finally 块。
-
例子:
javalock.lock(); try { // 临界区 } finally { lock.unlock(); }
-
可见性与有序性
句子 :可见性与有序性(如 volatile 禁止指令重排、确保修改对其他线程立即可见)
-
问题来源:处理器/编译器/JVM 都会做优化(寄存器缓存、指令重排等),导致一个线程对共享变量的写对其他线程不可见或执行顺序被打乱。
-
volatile 的语义:- 可见性:写入 volatile 的值,会被立即刷新到主内存,随后读 volatile 的线程能看到最新值。
- 禁止特定重排序:volatile 写之前的操作不会被重排到 volatile 写之后,volatile 读之后的操作不会被重排到 volatile 读之前。(形成 happens-before 关系)
- 不保证原子性:
volatile count; count++仍然是读-改-写三步,非原子。
-
常见用法:标志位(stop flag)、双重检验锁(DCL)里保证
instance 的可见性(配合volatile)。
原子性
句子 :原子性(如 Atomic 原子类基于 CAS 无锁更新避免竞争)
-
原子操作:一个不可被线程调度中断的操作(要么全部执行,要么完全不执行)。
-
AtomicInteger、AtomicReference 等:-
基于 CPU 的 CAS(Compare-And-Swap)实现:比较内存中当前值和预期值,相等则写入新值;否则失败并可重试。
-
优点:非阻塞,性能好(在低到中等争用下优于锁)。
-
例子:
javaAtomicInteger ai = new AtomicInteger(0); ai.incrementAndGet(); // 原子递增
-
-
CAS 的问题:
- ABA 问题:值从 A -> B -> A,会让 CAS 误判;解决方案有
AtomicStampedReference(带版本号)或使用更高层次的同步。
- ABA 问题:值从 A -> B -> A,会让 CAS 误判;解决方案有
-
重要:某些复杂操作仍需锁或原子组合(例如同时对两个变量进行一致性更新)。
并发安全的容器和工具类
句子 :并发安全的容器和工具类(如 ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue)
-
ConcurrentHashMap- 高层次语义:并发读写优化,不像 Hashtable 那样 global 锁。
- JDK8 实现要点:使用 CAS + synchronized(在桶上)+ 链表/树化;读操作通常无锁。
- 场景:高并发读写、计数(配合 compute/computeIfAbsent 等原子方法)。
-
CopyOnWriteArrayList- 写时复制:写(add/remove)会复制底层数组,读操作无锁、非常快。
- 优点:读多写少场景(如监听器列表)。
- 缺点:写成本高、内存消耗大,不适合频繁写。
-
BlockingQueue(如ArrayBlockingQueue,LinkedBlockingQueue)-
用于生产者-消费者:put/take 会在队列空/满时阻塞,天然线程安全且易用。
-
示例(简单生产者-消费者):
javaBlockingQueue<String> q = new ArrayBlockingQueue<>(100); // 生产线程: q.put(item); // 消费线程: String item = q.take();
-
线程封闭与不可变对象
-
不可变对象(immutable)
- 一旦构造完成,其状态不可改变,天然线程安全(如
String,Integer)。 - 制作不可变类要点:字段
final、不提供修改方法、构造中深拷贝可变输入。
- 一旦构造完成,其状态不可改变,天然线程安全(如
-
线程封闭(thread confinement)
- 把数据限定在某个线程内部(局部变量、线程私有对象),无需同步。
-
ThreadLocal- 为每个线程保存独立副本,适合保存线程相关状态(如数据库连接上下文、格式化器等)。
线程池(Executor 框架)
-
为什么用线程池:减少线程频繁创建销毁开销、控制并发数、统一管理异常与任务调度、提供可配置队列/拒绝策略。
-
常用类型:
newFixedThreadPool(固定线程数)、newCachedThreadPool(弹性线程池)、ScheduledThreadPoolExecutor(定时任务)。
读多写少:ReentrantReadWriteLock
- 语义:读锁允许多线程并发读取,写锁排他。适合读远多于写的场景。
- 要点:写锁时会阻塞所有读;有"公平/非公平"策略;要注意避免写操作长时间占用导致读阻塞。
线程协作工具
-
CountDownLatch:一次性等待 N 个事件完成(不可重用)。javaCountDownLatch latch = new CountDownLatch(N); // 工作线程: latch.countDown(); // 主线程: latch.await(); -
CyclicBarrier:等待一组线程都到达某点后再一起继续(可重复使用)。 -
Semaphore:控制并发许可数(比如控制并发访问资源的数量)。 -
Exchanger、Phaser 等:更复杂的协作场景。
常见错误/陷阱
- 忘记 unlock / 忘记在 finally 中释放锁 → 容易死锁或线程挂死。
- 误用 volatile 以为能保证复合操作的原子性 (例如
volatile int cnt; cnt++仍不安全)。 - 错误地把锁对象用为可变对象或包装类型 (例如
synchronized(Integer.valueOf(x))),会导致锁共享或不可预期。 - 发布逸出(this 在构造中被泄露) :构造期间把对象引用交给其他线程,导致不完全初始化就被访问。
- 死锁:多把锁按不同顺序请求会死锁。防范:按照固定顺序获取锁、使用 tryLock + 超时回退、减少锁粒度。
- 滥用 CopyOnWriteArrayList 在写多场景会性能崩塌。
面试常问追问(以及简短回答要点)
- Q:
synchronized和ReentrantLock的区别?
A:synchronized简洁、由 JVM 管理、自动释放;ReentrantLock更灵活(可中断、公平、tryLock、Condition),需手动 unlock。 - Q:
volatile的 happens-before 是什么?
A: 写volatile happens-before 后续读volatile,保证写对读可见并禁止特定重排序。 - Q: CAS 的 ABA 问题怎么解决?
A: 用带版本号的引用(AtomicStampedReference/AtomicMarkableReference)或在更高层用锁。 - Q:
ConcurrentHashMap如何做到并发?
A: JDK8 用 CAS + synchronized(桶/链表锁定) + 链表树化设计,读操作无锁。
新生代和老生代的区别
解答
Java 堆分为新生代和老生代。新生代主要存放新建对象 ,采用 复制算法 ,回收频繁且速度快;老生代存放生命周期长、晋升过来的对象 ,采用 标记-清除/标记-压缩算法,回收次数少但耗时长。两者配合让 GC 既快又高效。
详解
新生代
特点:
-
绝大部分对象刚创建就会很快死亡
例如短期字符串、临时变量等。
-
新生代又分为:
- Eden 区(大部分对象在这里创建)
- Survivor 区(S0、S1 两个,为对象存活交换使用)
-
回收策略:复制算法(Copying)
含义:把存活对象从一个区域复制到另一个区域,然后清空旧区域。
优点:
- 效率很高
- 简单
- 不会产生内存碎片
-
触发频率:Minor GC(频繁)
因为新生代小、对象变化快。
老生代
特点:
-
在新生代经历多次 GC 仍然存活的对象,会被晋升到老生代
常见场景:
- 单例对象
- 线程池、连接池对象
- 缓存对象
- 业务中长期存在的容器对象
-
核心特点:对象存活率高。
-
回收策略:
- 标记-清除(Mark-Sweep)
- 标记-整理/压缩(Mark-Compact)
这些算法比复制算法更复杂,也更慢。
-
触发频率:Major GC / Full GC(次数少,但耗时长)
| 项目 | 新生代(Young) | 老生代(Old) |
|---|---|---|
| 存储对象 | 新建对象、短生命周期 | 长生命周期对象 |
| GC 名称 | Minor GC | Major/Full GC |
| 回收频率 | 非常频繁 | 很少 |
| 回收速度 | 快 | 慢 |
| 回收算法 | 复制算法 | 标记清除 / 标记整理 |
| 对象死亡率 | 高 | 低 |
| 注意点 | 内存小、快速回收 | 一旦满了会触发 Full GC(可能卡顿) |
为什么要分新生代和老生代?
简单回答:
因为大多数对象朝生夕死,不必使用复杂GC算法;而少数长期存活对象才需要老生代的慢 GC。分代管理可以让 GC 的整体性能最大化。
Java中的String和C++有什么不同
解答
Java 的 String 是不可变对象(immutable),存放在堆里、带字符串常量池机制;C++ 的 std::string 是可变的普通类,直接管理一块连续内存,没有常量池。
详解
Java String:不可变、常量池、对象语义
不可变
Java 的 String 一旦创建,内容不能改变。
java
String s = "abc";
s = s + "d"; // 其实创建了新对象 "abcd"
特点:
- 线程安全(因为不能改)
- 可缓存(可以放进常量池复用)
- 适合作为 HashMap 的 key
- 字符串拼接需要注意性能(推荐 StringBuilder)
字符串常量池(String Pool)
Java 为了节省内存,引入了 String Pool。
java
String a = "hello";
String b = "hello"; // a 和 b 指向同一个对象
这种"同值字符串复用"只有 Java 有,C++ 没有。
Java String 是一个特殊的对象
底层本质是 final char[](或 byte[]) 。
它的不可变是语言级别 + JDK 实现层面的设计,保证每个String安全可靠。
为什么 Java String 要不可变?
答:
- 保证线程安全
- 提升性能(常量池复用)
- 能作为 HashMap key(hash 不改变)
- 安全(ClassLoader、URL 等一些敏感参数不能被改)
说一说网站中如何进行反爬虫
解答
网站反爬虫的核心就是:识别"机器行为",限制"异常流量",并保护"关键数据"。手段分为基础限制(UA、IP、频率)、行为识别(速度、路径、操作习惯)、技术校验(Cookie、Token、验证码、加密)、以及服务端的大数据风控策略。
详解
基础反爬(简单但有效)
这种策略成本低,大部分网站都会直接上。
① User-Agent 判断
拦截明显的爬虫 UA,例如:
python-requests
scrapy
curl/7.29
缺点:很容易伪造。
② IP 限流(Rate Limit)
同一 IP 在短时间内访问超过阈值 → 封禁或限速。
例子:
- 1 分钟超过 100 次访问 → 拉黑
- 同一接口 QPS > 10 → 限流
缺点:IP 可以更换(代理池)。
③ Referer 校验
只允许从本站页面跳转,不允许外部直接访问接口。
缺点:Referer 也可伪造。
④ Robots.txt
告诉正常爬虫不要抓哪些目录(如搜索引擎友好)。
缺点:恶意爬虫完全不遵守。
2. 技术反爬(高级反爬虫)
对机器来说非常难突破。
① Cookie + Session 行为验证
- 首次访问生成 Cookie
- 要求后续请求携带
- 判断 Cookie 生命周期、序列化、加密方式防作弊
② 请求参数加密/签名(Sign)
常见方式:
- MD5 签名
- AES/RC4 加密参数
- timestamp + 随机数
- 多个混淆字段让爬虫难以伪造
例如:
sign = md5(参数 + 时间戳 + 密钥)
爬虫无法知道密钥 → 请求被拒绝。
③ 验证码(强力阻断)
尤其是:
- 普通文本验证码
- 滑块验证码
- 点选验证码
- 行为验证码(声称"智能验证")
爬虫难以绕过,除非用 OCR + 人机协作成本极高。
④ JS 动态生成关键参数(JS 混淆)
浏览器执行一段复杂 JS 代码,生成:
- token
- cookie
- signature
- dynamic-timestamp
- 验证字段
爬虫想模拟 浏览器 + JS 执行 成本非常高
尤其是 混淆 JS(比如 360、Baidu、某些大厂常用)。
⑤ 前端混淆与加密
- JS 混淆(Obfuscator)
- 属性名随机
- API 路由动态
- 参数 AES 混淆
让爬虫很难逆向出数据接口。
行为/流量风控(高级)
这是大网站(抖音、淘宝、小红书)常用的核心手段。
系统会从多个维度判断用户是不是机器人:
① 访问速度
机器请求一般非常快:
- 10ms、20ms 连续发请求
- 人类行为不可能如此稳定
② 页面停留时间
用户打开页面------几秒/几十秒
爬虫打开页面------0秒瞬间访问下一页
③ 访问路径
正常用户:
列表页 -> 商品页 -> 详情页 -> 返回列表 -> 切换分类 -> ...
爬虫:
列表页 -> 列表页 -> 列表页 -> ...
路径非常"机械化"。