Java 锁学习笔记
Java 锁学习笔记1. 为什么需要锁1.1 多线程为什么会出问题多个线程同时操作同一份共享数据时可能产生数据竞争Race Condition。例如privateintcount0;publicvoidadd(){count;}count看起来只有一行但实际上可以理解成读取 count ↓ count 1 ↓ 写回 count假设当前count 0两个线程同时执行线程 A读取 0 线程 B读取 0 线程 A0 1 → 写入 1 线程 B0 1 → 写入 1最终可能得到count 1但实际上执行了两次add()期望结果应该是2。这就是并发情况下的数据竞争。1.2 锁解决什么问题锁的核心作用让多个线程访问共享资源时按照一定规则进行协调。最简单的理解多个线程 ↓ 竞争同一把锁 ↓ 同一时间只能一个线程进入 ↓ 其他线程等待注意锁不是让整个 Java 程序变成单线程而是让竞争同一把锁的线程互斥。2. synchronized —— 同步锁2.1 什么是 synchronizedsynchronized是 Java 提供的同步机制。最简单理解同一把锁同一时间只能有一个线程进入受保护的代码。例如privateintcount0;publicsynchronizedvoidadd(){count;}多个线程同时调用时线程 A → 获得锁 → 执行 add() 线程 B → 等待 线程 C → 等待 A 执行结束 → 释放锁 B → 获得锁 → 执行2.2 synchronized 锁住的到底是什么这是理解synchronized最重要的一点synchronized 锁的是对象而不是代码本身。synchronized 实例方法publicsynchronizedvoidadd(){count;}可以近似理解为publicvoidadd(){synchronized(this){count;}}也就是说实例方法锁住的是当前对象this。synchronized 代码块privatefinalObjectlocknewObject();publicvoidadd(){synchronized(lock){count;}}这里锁的是lock对象。static synchronizedpublicstaticsynchronizedvoidtest(){}锁的是MyService.class2.3 什么情况下两个线程会互斥关键不是“是不是 synchronized 方法”而是是不是竞争同一把锁例如publicsynchronizedvoidadd(){}publicsynchronizedvoidsubtract(){}如果两个方法属于同一个对象那么add() → 锁 this subtract() → 锁 this所以它们会互斥。即使是两个不同的方法也需要排队。2.4 不同对象的 synchronized 不会互相阻塞例如MyServiceserviceAnewMyService();MyServiceserviceBnewMyService();此时serviceA.add() ↓ 锁 serviceA serviceB.add() ↓ 锁 serviceB两把锁不同所以可以同时执行。因此同一把锁 → 互斥 不同的锁 → 可以并发2.5 synchronized 的可重入synchronized本身就是可重入的。例如publicsynchronizedvoidmethodA(){methodB();}publicsynchronizedvoidmethodB(){}执行过程线程 A ↓ 进入 methodA() ↓ 获得 this 的锁 ↓ 调用 methodB() ↓ 再次获得 this 的锁 ↓ 正常执行不会把自己锁死。所以可重入 同一个线程已经持有一把锁时可以再次获得同一把锁。注意可重入不代表多个线程可以同时执行。2.6 synchronized 常见错误不要每次创建新的锁对象synchronized(newObject()){// ...}因为每次调用都会创建不同的对象线程之间没有竞争同一把锁。应该使用共享的锁对象privatefinalObjectlocknewObject();publicvoidtest(){synchronized(lock){// ...}}3. ReentrantLock —— 可重入锁3.1 什么是 ReentrantLockReentrantLock中文可重入锁它是java.util.concurrent.locks包中的一个具体锁实现。privatefinalReentrantLocklocknewReentrantLock();3.2 最基本的使用方式privatefinalReentrantLocklocknewReentrantLock();publicvoidadd(){lock.lock();try{count;}finally{lock.unlock();}}执行流程lock() ↓ 获取锁 ↓ 执行代码 ↓ unlock() ↓ 释放锁3.3 为什么必须 finally unlock因为ReentrantLock是手动管理锁。标准写法lock.lock();try{// 业务}finally{lock.unlock();}如果业务代码抛出异常而没有finally可能导致锁无法正常释放。3.4 synchronized 和 ReentrantLock 的区别synchronizedsynchronized(lock){// ...}Java 自动管理进入代码块 → 自动获得锁 离开代码块 → 自动释放锁ReentrantLocklock.lock();try{// ...}finally{lock.unlock();}程序员自己控制lock() ↓ 获得锁 unlock() ↓ 释放锁所以可以简单理解synchronized 更简单ReentrantLock 给你更多手动控制能力。3.5 tryLock()ReentrantLock一个重要能力lock.tryLock();意思是尝试获取锁拿不到就不一直等待。例如if(lock.tryLock()){try{// 执行任务}finally{lock.unlock();}}else{System.out.println(当前已经有人执行了);}区别lock() ↓ 拿不到 → 一直等 tryLock() ↓ 拿不到 → 返回 false还可以lock.tryLock(3,TimeUnit.SECONDS);表示最多等待 3 秒。3.6 ReentrantLock 也是可重入的同一个线程已经获得lock后可以再次获得同一把锁不会把自己锁死。4. ReentrantReadWriteLock —— 读写锁4.1 为什么需要读写锁假设 Java 内存中有一个共享缓存privateMapString,StringcachenewHashMap();大量线程读取cache.get(user);偶尔才修改cache.put(user,Tom);如果所有操作都使用普通互斥锁即使多个线程都只是读取也需要排队。读写锁就是解决这个问题的。4.2 基本写法通常这样定义privatefinalReadWriteLocklocknewReentrantReadWriteLock();这里ReadWriteLock ↓ 接口 ReentrantReadWriteLock ↓ 具体实现类似ListStringlistnewArrayList();4.3 读锁publicStringget(Stringkey){lock.readLock().lock();try{returncache.get(key);}finally{lock.readLock().unlock();}}多个线程可以同时获得读锁线程 A → 读锁 → 读取 线程 B → 读锁 → 读取 线程 C → 读锁 → 读取所以读 读可以并发。4.4 写锁publicvoidput(Stringkey,Stringvalue){lock.writeLock().lock();try{cache.put(key,value);}finally{lock.writeLock().unlock();}}写锁更加严格读 写 → 互斥 写 写 → 互斥总结读锁 写锁 读锁 ✅ ❌ 写锁 ❌ ❌4.5 ReentrantReadWriteLock 也是可重入的ReentrantReadWriteLock也是可重入锁。例如同一个线程持有写锁后可以再次获得同一个写锁。因此synchronized ReentrantLock ReentrantReadWriteLock都支持可重入。5. StampedLock —— 邮戳锁5.1 StampedLock 是什么StampedLock提供读锁 写锁 乐观读其中最重要的特色是乐观读Optimistic Read。5.2 什么是乐观读普通读锁我要读 ↓ 先获得读锁 ↓ 读取数据 ↓ 释放读锁乐观读我要读 ↓ 先不真正加读锁 ↓ 直接读取 ↓ 最后检查期间有没有发生写操作也就是我乐观地认为读取过程中不会有人修改数据。5.3 基本代码privateintx10;privateinty20;privatefinalStampedLocklocknewStampedLock();publicintgetTotal(){longstamplock.tryOptimisticRead();intcurrentXx;intcurrentYy;if(!lock.validate(stamp)){stamplock.readLock();try{currentXx;currentYy;}finally{lock.unlockRead(stamp);}}returncurrentXcurrentY;}不用死记代码重点理解流程开始读取 ↓ 乐观读取 ↓ 检查读取期间有没有写操作 ↓ 没有 → 读取结果有效 有 → 重新获得读锁 → 重新读取5.4 为什么叫“乐观”因为它采用的是一种乐观思想我认为冲突大概率不会发生所以先操作最后再检查。5.5 乐观读和乐观锁两者思想很接近乐观读 ↓ 先读 ↓ 最后验证 ↓ 发现冲突 → 重新读数据库中的乐观锁也常采用类似思路先读取数据 ↓ 记录 version ↓ 执行更新 ↓ 检查 version 是否变化 ↓ 没有变化 → 成功 发生变化 → 冲突因此乐观读采用了和乐观锁类似的并发控制思想但两者不是同一个机制。5.6 StampedLock 不可重入前面几个synchronized ReentrantLock ReentrantReadWriteLock都是可重入的。但是StampedLock 不支持可重入。这一点需要特别记住。6. 乐观锁 vs 悲观锁6.1 悲观锁核心思想我认为冲突很可能发生所以先把资源锁住再操作。流程先锁 ↓ 执行操作 ↓ 释放锁例如synchronized(lock){// 修改共享数据}线程线程 A → 拿到锁 → 执行 线程 B → 拿不到 → 等待这体现了典型的悲观思想“我认为别人可能会同时操作所以我先把门锁起来。”6.2 乐观锁核心思想我认为冲突不一定发生所以先操作最后检查有没有冲突。流程先操作 ↓ 最后检查 ↓ 没有冲突 → 成功 发生冲突 → 失败 / 重试 / 重新读取常见实现思路是版本号version 1更新时UPDATEaccountSETbalance900,versionversion1WHEREid1ANDversion1;如果其他线程已经把version 1改成version 2那么这次更新就不会成功。6.3 悲观锁 vs 乐观锁悲观锁乐观锁核心思想先锁住先操作冲突处理事前阻止事后检查冲突发生时等待失败 / 重试Java 中的典型体现synchronized、ReentrantLockCAS、版本号等思想数据库例子FOR UPDATEversion 字段6.4 “悲观锁”和“乐观锁”不是具体 Java 类这一点非常重要。Java 中没有PessimisticLock这样的标准锁类。“悲观锁”和“乐观锁”描述的是一种并发控制思想。例如synchronized ↓ Java 同步机制 ↓ 体现悲观并发控制思想ReentrantLock ↓ Java 可重入锁 ↓ 体现悲观并发控制思想而StampedLock 的乐观读 ↓ 体现乐观并发控制思想数据库也同样如此SELECT ... FOR UPDATE ↓ 悲观锁思想version / CAS ↓ 乐观锁思想