乐观锁
乐观锁是一种乐观思想,假定当前环境是读多写少,遇到并发写的概率比较低,读数据时认为别的线程不会正在进行修改(所以没有上锁)。写数据时,判断当前 与期望值是否相同,如果相同则进行更新(更新期间加锁,保证是原子性的)。
Java 中的乐观锁: CAS,比较并替换,比较当前值(主内存中的值),与预期值(当前线程中的值,主内存中值的一份拷贝)是否一样,一样则更新,否则继续进行 CAS 操作。
如图所示,可以同时进行读操作,读的时候其他线程不能进行写操作。
悲观锁
悲观锁是一种悲观思想,即认为写多读少,遇到并发写的可能性高,每次去拿数据的时候都认为其他线程会修改,所以每次读写数据都会认为其他线程会修改,所以每次读写数据时都会上锁。其他线程想要读写这个数据时,会被这个线程 block,直到这个线程释放锁然后其他线程获取到锁。
Java 中的悲观锁: synchronized 修饰的方法和方法块、ReentrantLock。
如图所示,只能有一个线程进行读操作或者写操作,其他线程的读写操作均不能进行。
自旋锁
自旋锁是一种技术: 为了让线程等待,我们只须让线程执行一个忙循环(自旋)。
现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
自旋锁的优点: 避免了线程切换的开销。挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给 Java 虚拟机的并发性能带来了很大的压力。
自旋锁的缺点: 占用处理器的时间,如果占用的时间很长,会白白消耗处理器资源,而不会做任何有价值的工作,带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。
自旋次数默认值:10 次,可以使用参数-XX:PreBlockSpin 来自行更改。
自适应自旋: 自适应意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状态预测就会越来越精准。
Java 中的自旋锁: CAS 操作中的比较操作失败后的自旋等待。
可重入锁(递归锁)
可重入锁是一种技术: 任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞。
可重入锁的原理: 通过组合自定义同步器来实现锁的获取与释放。
- 再次获取锁:识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。获取锁后,进行计数自增,
- 释放锁:释放锁时,进行计数自减。
Java 中的可重入锁: ReentrantLock、synchronized 修饰的方法或代码段。
可重入锁的作用: 避免死锁。
面试题 1: 可重入锁如果加了两把,但是只释放了一把会出现什么问题?
答:程序卡死,线程不能出来,也就是说我们申请了几把锁,就需要释放几把锁。
面试题 2: 如果只加了一把锁,释放两次会出现什么问题?
答:会报错,java.lang.IllegalMonitorStateException。
读写锁
读写锁是一种技术: 通过 ReentrantReadWriteLock 类来实现。为了提高性能, Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的。
读锁: 允许多个线程获取读锁,同时访问同一个资源。
写锁: 只允许一个线程获取写锁,不允许同时访问同一个资源。
使用方法:
/**
* 创建一个读写锁
* 它是一个读写融为一体的锁,使用的时候需要转换
*/
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
// 获取读锁
rwLock.readLock().lock();
// 释放读锁
rwLock.readLock().unlock();
// 创建一个写锁
rwLock.writeLock().lock();
// 写锁 释放
rwLock.writeLock().unlock();
Java 中的读写锁:ReentrantReadWriteLock
公平锁
公平锁是一种思想: 多个线程按照申请锁的顺序来获取锁。在并发环境中,每个线程会先查看此锁维护的等待队列,如果当前等待队列为空,则占有锁,如果等待队列不为空,则加入到等待队列的末尾,按照 FIFO 的原则从队列中拿到线程,然后占有锁。
非公平锁
非公平锁是一种思想: 线程尝试获取锁,如果获取不到,则再采用公平锁的方式。多个线程获取锁的顺序,不是按照先到先得的顺序,有可能后申请锁的线程比先申请的线程优先获取锁。
优点: 非公平锁的性能高于公平锁。
缺点: 有可能造成线程饥饿(某个线程很长一段时间获取不到锁)
Java 中的非公平锁:synchronized 是非公平锁,ReentrantLock 通过构造函数指定该锁是公平的还是非公平的,默认是非公平的。
共享锁
共享锁是一种思想: 可以有多个线程获取读锁,以共享的方式持有锁。和乐观锁、读写锁同义。
Java 中用到的共享锁: ReentrantReadWriteLock。
独占锁
独占锁是一种思想: 只能有一个线程获取锁,以独占的方式持有锁。和悲观锁、互斥锁同义。
Java 中用到的独占锁: synchronized,ReentrantLock
重量级锁
重量级锁是一种称谓: synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本身依赖底层的操作系统的 Mutex Lock 来实现。操作系统实现线程的切换需要从用户态切换到核心态,成本非常高。这种依赖于操作系统 Mutex Lock 来实现的锁称为重量级锁。为了优化 synchonized,引入了轻量级锁,偏向锁。
Java 中的重量级锁:synchronized
轻量级锁
轻量级锁是 JDK6 时加入的一种锁优化机制: 轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量。轻量级是相对于使用操作系统互斥量来实现的重量级锁而言的。轻量级锁在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁将不会有效,必须膨胀为重量级锁。
优点: 如果没有竞争,通过 CAS 操作成功避免了使用互斥量的开销。
缺点: 如果存在竞争,除了互斥量本身的开销外,还额外产生了 CAS 操作的开销,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。
偏向锁
偏向锁是 JDK6 时加入的一种锁优化机制: 在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不去做了。偏是指偏心,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对 Mark Word 的更新操作等)。
优点: 把整个同步都消除掉,连 CAS 操作都不去做了,优于轻量级锁。
缺点: 如果程序中大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的。
分段锁
分段锁是一种机制: 最好的例子来说明分段锁是 ConcurrentHashMap。
ConcurrentHashMap 原理:
它内部细分了若干个小的 HashMap,称之为段(Segment)。默认情况下一个 ConcurrentHashMap 被进一步细分为 16 个段,既就是锁的并发度。如果需要在 ConcurrentHashMap 添加一项 key-value,并不是将整个 HashMap 加锁,而是首先根据 hashcode 得到该 key-value 应该存放在哪个段中,然后对该段加锁,并完成 put 操作。在多线程环境中,如果多个线程同时进行 put 操作,只要被加入的 key-value 不存放在同一个段中,则线程间可以做到真正的并行。
线程安全:ConcurrentHashMap 是一个 Segment 数组, Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全
互斥锁
互斥锁与悲观锁、独占锁同义,表示某个资源只能被一个线程访问,其他线程不能访问。
- 读-读互斥
- 读-写互斥
- 写-读互斥
- 写-写互斥
Java 中的同步锁:synchronized
同步锁
同步锁与互斥锁同义,表示并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。
Java 中的同步锁: synchronized
死锁
死锁是一种现象:如线程 A 持有资源 x,线程 B 持有资源 y,线程 A 等待线程 B 释放资源 y,线程 B 等待线程 A 释放资源 x,两个线程都不释放自己持有的资源,则两个线程都获取不到对方的资源,就会造成死锁。
Java 中的死锁不能自行打破,所以线程死锁后,线程不能进行响应。所以一定要注意程序的并发场景,避免造成死锁。
锁粗化
锁粗化是一种优化技术: 如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作都是出现在循环体体之中,就算真的没有线程竞争,频繁地进行互斥同步操作将会导致不必要的性能损耗,所以就采取了一种方案:把加锁的范围扩展(粗化)到整个操作序列的外部,这样加锁解锁的频率就会大大降低,从而减少了性能损耗。
锁消除
锁消除是一种优化技术: 就是把锁干掉。当 Java 虚拟机运行时发现有些共享数据不会被线程竞争时就可以进行锁消除。
判断共享数据会不会被线程竞争的方法:
利用逃逸分析技术:分析对象的作用域,如果对象在 A 方法中定义后,被作为参数传递到 B 方法中,则称为方法逃逸;如果被其他线程访问,则称为线程逃逸。
在堆上的某个数据不会逃逸出去被其他线程访问到,就可以把它当作栈上数据对待,认为它是线程私有的,同步加锁就不需要了。
synchronized
synchronized是 Java 中的关键字:用来修饰方法、对象实例。属于独占锁、悲观锁、可重入锁、非公平锁。
- 作用于实例方法时,锁住的是对象的实例(this);
- 当作用于静态方法时,锁住的是 Class 类,相当于类的一个全局锁, 会锁所有调用该方法的线程;
- synchronized 作用于一个非 NULL 的对象实例时,锁住的是所有以该对象为锁的代码块。它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
每个对象都有个 monitor 对象, 加锁就是在竞争 monitor 对象,代码块加锁是在代码块前后分别加上 monitorenter 和 monitorexit 指令来实现的,方法加锁是通过一个标记位来判断的。
Lock 和 synchronized 的区别
自动挡和手动挡的区别
Lock: 是 Java 中的接口,可重入锁、悲观锁、独占锁、互斥锁、同步锁。
- Lock 需要手动获取锁和释放锁。就好比自动挡和手动挡的区别
- Lock 是一个接口,而 synchronized 是 Java 中的关键字, synchronized 是内置的语言实现。
- synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁。
- Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用 synchronized 时,等待的线程会一直等待下去,不能够响应中断。
- 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。
- Lock 可以通过实现读写锁提高多个线程进行读操作的效率。
synchronized 的优势:足够清晰简单,只需要基础的同步功能时,用 synchronized。
Lock 应该确保在 finally 块中释放锁。如果使用 synchronized,JVM 确保即使出现异常,锁也能被自动释放。
使用 Lock 时,Java 虚拟机很难得知哪些锁对象是由特定线程锁持有的。
ReentrantLock 和 synchronized 的区别
ReentrantLock是 Java 中的类 : 继承了 Lock 类,可重入锁、悲观锁、独占锁、互斥锁、同步锁。
相同点:
- 主要解决共享变量如何安全访问的问题
- 都是可重入锁,也叫做递归锁,同一线程可以多次获得同一个锁,
- 保证了线程安全的两大特性:可见性、原子性。
不同点:
- ReentrantLock 就像手动汽车,需要显示的调用 lock 和 unlock 方法, synchronized 隐式获得释放锁。
- ReentrantLock 可响应中断, synchronized 是不可以响应中断的,ReentrantLock 为处理锁的不可用性提供了更高的灵活性
- ReentrantLock 是 API 级别的, synchronized 是 JVM 级别的
- ReentrantLock 可以实现公平锁、非公平锁,默认非公平锁,synchronized 是非公平锁,且不可更改。
- ReentrantLock 通过 Condition 可以绑定多个条件
分布式锁
在真实业务中,我们用到锁的场景实际上非常多。比如打车业务中的抢单。以往我们在单机环境中,比如说 Java 我们可以使用 Synchronized 或者 ReentrantLock 来保证共享资源并发的安全性。相信大家都已经比较熟悉了,如下图所示,在同一个 JVM 进程中,Thread1 获得锁之后,对共享资源进行操作,其他线程未获得锁的线程只能等待 Thread1 释放后才能进行对应的操作。
随着业务的不断发展,单机系统已经扛不住流量压力了。这时我们就需要考虑升级服务器,提升机器配置或者扩展机器节点。提升机器配置性能提升有限,我们采用的方案一般都是加机器!!!
加机器看似很完美,性能也提升上来了,流量瓶颈也解决了。但是我们的系统从单机系统演化成分布式系统了。又会产生一些新的问题,事物问题、ID问题、锁问题等等。比如说锁,我们在单机环境中一个 Java 关键字就搞定了。JVM 帮我们去解决了并发竞争问题,但是分布式系统,我们的服务可能是在不同的进程,不同的机器。此时,JVM 就无能为力了,这时我们就需要自身去解决跨进程共享资源安全性的问题。
分布式与单机情况下最大的不同在于其不是多线程而是多进程。多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。
分布式锁的实现需要考虑的因素:
- 锁必须是 互斥 的,即在任何时候只能有一个线程持有锁。
- 锁必须是 可重入 的,即如果一个线程已经持有了锁,那么它可以多次获取该锁而不会发生死锁。
- 锁必须是 安全 的,即如果一个线程获得了锁,那么即使崩溃或失去连接,锁也必须被释放。
实现一个分布式锁,需要满足的条件:
- 高性能:我们这个分布式锁可能会有很多的服务器来获取,所以我们一定要能够高性能的获取和释放;
- 高可用:不能因为某一个分布式锁获取的服务不可用,导致所有服务都拿不到或释放锁,所以要满足高可用要求;
- 锁失效机制:假设某个应用获取到锁之后,一直没有来释放锁,可能服务本身已经挂掉了,不能一直不释放,导致其他服务一直获取不到锁;
- 非阻塞特性:在某个服务来获取锁时,假设该锁已经被另一个服务获取,我们要能直接返回失败,不能一直等待。
当多个进程或者线程需要对共享资源进行访问时,为了保证资源访问的互斥性,需要使用锁来进行控制。在分布式系统中,由于数据存储和处理在多个节点上进行,因此需要使用分布式锁来保证对共享资源的访问互斥性和一致性。
常见的分布式锁实现方式包括基于数据库、基于缓存和基于 ZooKeeper 等方式。
MySQL实现分布式锁
基于数据库的分布式锁实现方式,通常使用数据库的事务特性来保证锁的正确性,将锁状态存储在数据库中。实现方式可以是:
- 通过使用数据库的行级锁来保证对同一资源的访问互斥,例如使用 MySQL 的 SELECT ... FOR UPDATE 或者 UPDATE ... WHERE 语句来实现分布式锁。
- 通过使用数据库的唯一索引来实现分布式锁,例如在 MySQL 中可以使用 INSERT ... ON DUPLICATE KEY UPDATE 语句来实现分布式锁。在此方式下,需要将资源的唯一标识作为唯一索引,每次需要获取锁时,尝试向数据库中插入一条记录,如果已存在,则更新记录,否则插入一条新记录,插入成功即可获得锁。
优点:
- 实现简单:相对于基于 ZooKeeper 的实现方式,基于 MySQL 的实现方式较为简单,只需要使用 MySQL 的事务和唯一索引即可实现分布式锁。
- 不需要引入额外的依赖:MySQL 作为一种常见的数据库,不需要引入额外的依赖库即可使用,因此对于没有使用 ZooKeeper 的系统来说,可以较为方便地引入基于 MySQL 的分布式锁。
缺点:
- 对 MySQL 的可用性和性能要求较高:和基于 ZooKeeper 的实现方式一样,基于 MySQL 的实现方式也需要保证 MySQL 集群的可用性和性能,否则将会影响到整个系统的正常运行。
- 不适合高频率的加锁和解锁操作:基于 MySQL 的实现方式使用数据库的事务机制实现锁的管理,因此在高频率的加锁和解锁操作场景下,会产生大量的数据库连接和事务操作,导致性能瓶颈。
- 不适合长时间占用锁资源:由于 MySQL 的锁实现方式是使用行锁或表锁,因此不适合长时间占用锁资源,否则会导致锁冲突,进而影响到整个系统的性能和可用性。
Zookeeper实现分布式锁
基于 ZooKeeper 的分布式锁实现方式,通常使用 ZooKeeper 的节点特性来实现,例如使用 ZooKeeper 的临时节点来实现分布式锁。实现方式可以是:
- 在 ZooKeeper 上创建临时节点,每个节点对应一把锁,锁的持有者在创建节点时添加自己的标识。当锁的持有者释放锁时,删除对应的节点即可。
- 利用 ZooKeeper 提供的 watch 机制,当节点被删除时,可以通过 watch 机制进行通知,从而实现锁的等待机制。
优点:
- 实现简单:ZooKeeper 已经实现了分布式协调服务的机制,提供了临时节点、watch 机制等原语,可以方便地实现分布式锁。
- 具有一定的容错能力:ZooKeeper 是一个高可用的分布式协调服务,能够保证分布式锁在出现故障时的可用性。
- 对于分布式环境下的锁管理效果良好:由于 ZooKeeper 本身就是一个分布式服务,因此在分布式环境下使用 ZooKeeper 来实现分布式锁能够较好地解决分布式环境下的锁管理问题。
缺点:
- 对 ZooKeeper 的可用性和性能要求较高:ZooKeeper 的可用性和性能直接影响到分布式锁的性能和可用性,如果 ZooKeeper 集群不稳定或者出现性能瓶颈,将会影响到整个系统的正常运行。
- 依赖性较强:使用基于 ZooKeeper 的分布式锁需要引入 ZooKeeper 的客户端库,因此对于没有使用 ZooKeeper 的系统来说,需要增加额外的依赖。
- 实现稍微复杂:相对于基于缓存和数据库的实现方式,基于 ZooKeeper 的实现方式稍微复杂一些,需要掌握 ZooKeeper 的相关知识和 API。
缓存的分布式锁实现方式
基于缓存的分布式锁实现方式,通常使用分布式缓存来存储锁状态,例如使用 Redis 的 SETNX 操作来实现分布式锁。实现方式可以是:
- 使用 Redis 的 SETNX 命令来获取锁,SETNX 命令可以在指定的键不存在时设置该键的值,如果键已经存在则不做任何操作,因此可以使用 SETNX 命令来实现分布式锁。
- 可以使用 Redis 的带有过期时间的 SETNX 或者类似的命令,保证锁的自动过期,避免死锁。
优点:
- 实现简单:基于缓存的方式实现分布式锁的实现方式较为简单,只需要使用缓存的 CAS 原语和过期时间即可实现。
- 支持高并发:缓存系统一般支持高并发的读写操作,因此可以较好地支持高并发下的分布式锁场景。
- 可以较好地解决死锁问题:基于缓存的方式实现分布式锁的过期时间机制可以较好地避免死锁问题,一旦某个节点因为故障导致锁没有被释放,那么在过期时间到达之后,该节点的锁会自动失效,从而不会影响到整个系统的正常运行。
缺点:
- 依赖于缓存系统的可用性和性能:和基于 ZooKeeper 和 MySQL 的实现方式一样,基于缓存的实现方式也依赖于缓存系统的可用性和性能,如果缓存系统不稳定或者出现性能瓶颈,将会影响到整个系统的正常运行。
- 可靠性较低:由于缓存系统本身就是一个内存存储系统,因此相比于 ZooKeeper 和 MySQL 的实现方式,基于缓存的实现方式的可靠性要稍低一些。在发生故障时,缓存中的数据可能会丢失,因此需要设计合理的故障恢复机制。
产生死锁的必要条件
- 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一个进程所占有。
- 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完后由自己释放。
- 环路等待条件:当发生死锁时,必然存在一个进程——资源的环形链。