Java 多线程系列Ⅴ

2024-02-03 09:20:50 浏览数 (1)

一、乐观锁 & 悲观锁

乐观锁:乐观锁体现的是悲观锁的反面。它是一种积极的思想,它总是认为数据是不会被修改的,所以是不会对数据上锁的。但是乐观锁在更新的时候会去判断数据是否被更新过。乐观锁的实现方案一般有两种(版本号机制和CAS)。乐观锁适用于读多写少的场景,这样可以提高系统的并发量。在Java中 java.util.concurrent.atomic下的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

悲观锁:当我们要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发的发生。

为什么叫做悲观锁呢?因为这是一种对数据的修改抱有悲观态度的并发控制方式。我们一般认为数据被并发修改的概率比较大,所以需要在修改之前先加锁。

利用悲观锁的解决思路是,我们认为数据修改产生冲突的概率比较大,所以在更新之前,我们显示的对要修改的记录进行加锁,直到自己修改完再释放锁。加锁期间只有自己可以进行读写,其他事务只能读不能写。

二、重量级锁 & 轻量级锁

重量级锁:使用了一个称为“对象头”的结构来标识一个对象是否被锁定。当一个线程试图获取一个被其他线程持有的对象的锁时,将会发生阻塞,直到持有锁的线程释放该锁。这种机制的优点是实现简单,适用于大多数情况。但是,由于需要等待其他线程释放锁,因此可能会造成线程的上下文切换,从而增加了系统的开销。也涉及大量的内核态和用户态的切换,很容易引发线程的调度。

轻量级锁:使用了一种称为“偏向锁”的机制,该机制可以避免线程的上下文切换。轻量级锁的实现原理是在对象头中增加一个标记位,表示该对象是否被锁定。当一个线程试图获取一个被其他线程持有的对象的锁时,标记位将被设置为锁定状态,而该线程将继续执行,不会发生阻塞。当其他线程释放该锁时,标记位将被清除。轻量级锁的优点是可以减少系统的开销,提高程序的执行效率。但是,如果多个线程同时尝试获取同一个对象的锁,则可能会造成自旋等待,从而浪费CPU资源。

需要注意的是,用户态的时间成本是比较可控的,而内核态的时间成本不太可控

例如在程序中,有一个共享资源被一个线程持有,其他线程需要获取该资源并进行操作。

  • 使用重量级锁,那么其他线程会等待持有资源的线程释放该锁,然后才能获取该资源并进行操作。这会造成线程的上下文切换并增加系统的开销。
  • 使用轻量级锁,则其他线程会尝试获取该资源的锁并设置标记位,如果成功则继续执行操作;如果失败则会自旋等待,直到持有锁的线程释放该锁为止。这可以减少系统的开销并提高程序的执行效率。

三、自旋锁 & 挂起等待锁

自旋锁:是轻量级锁的一种典型实现(通常是存用户态的,不需要经过内核态);是一种基于原子操作的锁,它利用了CPU的高速缓存和指令重排等特性。自旋锁在尝试获取锁时,会一直循环检查锁是否可用,直到获取到锁为止。如果锁已经被其他线程持有,则当前线程会一直循环检查该锁的标记位,直到获取到锁或者线程被阻塞为止。自旋锁的优点是实现简单,适用于短期持有锁的情况。但是,如果锁被持有的时间较长,则当前线程可能会浪费CPU资源,并且会占用较多的CPU缓存资源,影响程序的性能。

挂起等待锁:是重量级锁的一种典型实现。(通常是通过内核机制来实现挂起等待);基于线程挂起和唤醒的锁,它需要配合操作系统实现。当一个线程试图获取一个被其他线程持有的锁时,它会将自己的状态设置为挂起状态,并将自己放入等待队列中。当持有锁的线程释放该锁时,会唤醒等待队列中的一个线程,该线程将重新尝试获取该锁。挂起等待锁的优点是可以减少CPU资源的浪费,并且可以避免线程的上下文切换。但是,由于需要配合操作系统实现,因此实现起来比较复杂,并且可能会引入死锁等问题。

有一个共享资源,被一个线程持有,其他线程需要获取该资源并进行操作。

  • 使用自旋锁,那么其他线程会一直循环检查该资源的标记位,直到获取到锁为止。这会浪费CPU资源并且会占用较多的CPU缓存资源。
  • 使用挂起等待锁,则其他线程会将自己的状态设置为挂起状态,并将自己放入等待队列中。当持有资源的线程释放该锁时,等待队列中的一个线程会被唤醒并重新尝试获取该锁。这可以减少CPU资源的浪费并且避免线程的上下文切换。

四、互斥锁 & 读写锁

互斥锁只允许一个线程在同一时刻访问共享资源。 当一个线程获取了互斥锁并访问共享资源时,其他线程将被阻塞,直到该线程释放互斥锁。互斥锁的主要问题是它可能会导致“忙等待”,即当一个线程持有互斥锁时,其他线程会一直等待,直到该线程释放互斥锁。这会浪费CPU资源,并且如果等待时间过长,可能会导致死锁等问题。

读写锁允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。 读写锁分为共享锁独占锁两种类型。多个线程可以同时持有共享锁并读取共享资源,但只有一个线程可以持有独占锁并写入共享资源。当一个线程尝试获取独占锁时,其他线程的读取操作将被阻塞,直到该线程释放独占锁。读写锁的主要优点是可以提高并发性能,因为多个线程可以同时读取共享资源,而不会被阻塞。

  1. 读加锁和读加锁之间,不互斥。
  2. 写加锁和写加锁之间,互斥。
  3. 读加锁和写加锁之间,互斥。

Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁:

  • ReentrantReadWriteLock.ReadLock 类表示一个读锁。这个对象提供了 lock / unlock 方法进行加锁解锁。
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁。这个对象也提供了 lock / unlock 方法进行加锁解锁。

举一个生活中很经典的例子,一个线程代表银行工作人员,另一个线程代表客户。银行工作人员需要修改账户余额,而客户需要查询账户余额。

  • 使用互斥锁,那么银行工作人员和客户会相互等待对方释放互斥锁,这会导致死锁等问题。
  • 使用读写锁,银行工作人员可以获取独占锁并修改账户余额,而客户可以同时获取共享锁并查询账户余额。这样,多个客户可以同时查询账户余额而不被阻塞,但只有一个银行工作人员可以修改账户余额并获取独占锁。

五、可重入锁 & 不可重入锁

可重入锁:可重入锁的字面意思是“可以重新进入的锁”,即允许一个线程多次获取同一个锁,而不会产生死锁。 在可重入锁的实现中,当一个线程尝试获取锁时,会检查该锁的计数器。如果计数器为零,表示该锁没有被其他线程持有,因此该线程可以获取锁并将计数器加一。如果计数器不为零,表示该锁已经被其他线程持有,因此该线程不能获取锁,但可以通过递归调用的方式再次尝试获取锁。可重入锁的主要优点是可以避免死锁,因为一个线程可以多次尝试获取同一个锁。

比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁。

不可重入锁不允许一个线程多次获取同一个锁。 在不可重入锁的实现中,当一个线程尝试获取锁时,如果该锁已经被其他线程持有,那么该线程将被阻塞,直到持有锁的线程释放该锁为止。不可重入锁的主要优点是实现简单,因为不需要维护计数器等额外的数据结构。

:JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。

六、公平锁 & 非公平锁

公平锁:

公平锁自然是遵循FIFO(先进先出)原则的,先到的线程会优先获取资源,后到的会进行排队等待

  • 优点: 所有的线程都能得到资源,不会饿死在队列中。适合大任务
  • 缺点: 吞吐量会下降,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销大

非公平锁:

多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

  • 优点: 可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必去唤醒所有线程,会减少唤起线程的数量。
  • 缺点: 你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁

公平锁效率低原因:

公平锁要维护一个队列,后来的线程要加锁,即使锁空闲,也要先检查有没有其他线程在 wait,如果有自己要挂起,加到队列后面,然后唤醒队列最前面线程。这种情况下相比较非公平锁多了一次挂起和唤醒

线程切换的开销,其实就是非公平锁效率高于公平锁的原因,因为非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销。

0 人点赞