赞
踩
接上次博客:
目录
(3) 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
JUC(java.util.concurrent) 的常见类
ReentrantLock 和 synchronized 的区别:
1、Collections.synchronizedList(new ArrayList);
(1) Hashtable(只是简单的把关键方法加上了 synchronized 关键字)
(3)HashTable、HashMap 和 ConcurrentHashMap的区别:
实际开发中,涉及到的锁不仅仅是 synchronized 这一种,甚至不仅仅局限于Java中。
乐观锁(Optimistic Locking)和悲观锁(Pessimistic Locking)都是在多用户环境下管理并发访问共享资源的方法,通常用于数据库管理系统和多线程编程中。
乐观、悲观是指 “锁的特性”,一类锁,不是具体的一把锁~
悲观乐观,是对后续锁冲突是否激烈(频繁)给出的预测。
如果预测接下来锁冲突的概率不大,就可以少做一些工作,称为乐观锁。
如果预测接下来锁冲突的概率很大,就应该多做一些工作,称为悲观锁。
乐观锁:
设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并 发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
具体一点来说:
1. 乐观锁(Optimistic Locking):
2. 悲观锁(Pessimistic Locking):
选择乐观锁还是悲观锁取决于应用程序的需求和特定的并发情况。乐观锁通常用于高并发读取的情况,而悲观锁通常用于需要更强的数据一致性和避免数据冲突的情况。
重量级锁(Heavyweight Lock)和轻量级锁(Lightweight Lock)是用于多线程编程中管理共享资源访问的两种不同锁定机制。它们在锁定和解锁的开销、竞争情况以及适用场景等方面有所不同。
轻量级锁,锁的开销比较小;重量级锁,锁的开销比较大。
乐观锁通常是轻量级锁,悲观锁通常是重量级锁。但是注意!!!只是通常情况下!
一个预测锁冲突的概率,一个是实际消耗的开销。
1. 重量级锁(Heavyweight Lock):
特点:
2. 轻量级锁(Lightweight Lock):
特点:
总之,重量级锁和轻量级锁都有其适用的场景和权衡。选择哪种锁取决于应用程序需求,以及对性能和可维护性的关注。
自旋锁(Spin Lock)和挂起等待锁(Mutex,Semaphore)是两种不同的锁机制,用于管理多线程或多进程之间对共享资源的访问。
自旋锁就属于是一种轻量级锁的典型实现;往往是在纯用户态实现的,比如使用一个while循环,不停的检查当前的锁是否被释放。如果没有就继续循环,释放了就获取到锁,从而结束循环。这其实是一个忙等,但是这里消耗CPU但是换来的是更快的响应速度。定时器的忙等是没有必要的,不如让出CPU。
挂起等待锁就属于重量级锁的一种典型实现;要借助系统API来实现,一旦出现锁竞争,就会在内核中触发一系列的动作,比如让这个线程进入阻塞的状态,暂时不参与CPU调度。而阻塞的开销是很大的。
1. 自旋锁(Spin Lock):
特点:
2. 挂起等待锁(Mutex,Semaphore):
特点:
选择自旋锁还是挂起等待锁取决于应用程序的需求、并发情况以及性能要求。自旋锁适合短小的临界区和低竞争情况,而挂起等待锁适合更复杂的情况,需要线程能够安全地休眠等待资源。
读写锁(Read-Write Lock)是一种用于多线程编程的同步机制,它允许多个线程同时读取共享资源,但在写操作时必须独占访问资源。读写锁在提高并发性能和资源共享方面非常有用,尤其是当读操作频繁而写操作相对较少的情况下。
这里把加锁分成两种:读加锁和写加锁。
是不是觉得有点熟悉?我们之前讲过数据库的读加锁和写加锁。
但是数据库写加锁是“写的时候不能读”,读加锁是“读的时候不能写”。
但是到了这里就不一样了:
读加锁:读的时候能读,但是不能写;
写加锁:写的时候不能读,也不能写。
读写锁有两种基本模式:
1. 读锁(Read Lock):
2. 写锁(Write Lock):
读写锁的主要优点是允许多个线程同时读取数据,提高了并发性能,特别适用于读远远多于写的场景,例如数据库系统中的数据查询。然而,它的缺点是写操作会阻塞其他所有的读和写操作,因此在写入操作频繁的情况下,可能会导致性能下降。
读写锁的使用通常需要注意以下事项:
在Java中,读写锁是通过java.util.concurrent包中的ReentrantReadWriteLock类来实现的。以下是一个简单的示例,展示了如何在Java中使用读写锁:
- import java.util.concurrent.locks.ReadWriteLock;
- import java.util.concurrent.locks.ReentrantReadWriteLock;
-
- public class ReadWriteLockExample {
- private static int sharedData = 0;
- private static ReadWriteLock rwLock = new ReentrantReadWriteLock();
-
- public static void main(String[] args) {
- Thread reader1 = new Thread(() -> {
- while (true) {
- rwLock.readLock().lock(); // 获取读锁
- System.out.println("Reader 1 reads sharedData: " + sharedData);
- rwLock.readLock().unlock(); // 释放读锁
- }
- });
-
- Thread reader2 = new Thread(() -> {
- while (true) {
- rwLock.readLock().lock(); // 获取读锁
- System.out.println("Reader 2 reads sharedData: " + sharedData);
- rwLock.readLock().unlock(); // 释放读锁
- }
- });
-
- Thread writer = new Thread(() -> {
- while (true) {
- rwLock.writeLock().lock(); // 获取写锁
- sharedData++;
- System.out.println("Writer writes sharedData: " + sharedData);
- rwLock.writeLock().unlock(); // 释放写锁
- }
- });
-
- reader1.start();
- reader2.start();
- writer.start();
- }
- }
在这个示例中,我们创建了一个ReentrantReadWriteLock实例来管理共享资源sharedData的读写访问。readLock()方法用于获取读锁,而writeLock()方法用于获取写锁。多个线程可以同时持有读锁,但只能有一个线程持有写锁,写锁是互斥的。
这个示例中的读操作示例和写操作示例在不断地循环,演示了多个读操作可以同时进行,但写操作会阻塞其他读写操作。
允许同一个线程多次获取同一把锁的锁被称为可重入锁。Java中的Reentrant开头命名的锁都是可重入锁,包括synchronized关键字锁。而Linux系统提供的mutex是一种不可重入锁。
下面是一些关于可重入锁和不可重入锁的更详细说明:
可重入锁(Reentrant Locks):
可重入性:允许同一线程在持有锁的情况下多次获取锁,而不会导致死锁。这在递归函数中非常有用,因为递归函数可能需要多次获取同一把锁。
Java的Reentrant系列:Java中提供了一系列可重入锁,如ReentrantLock和ReentrantReadWriteLock,它们都支持同一线程多次获取锁。
synchronized关键字锁也是可重入的:在Java中,通过synchronized关键字获得的锁也是可重入的,同一线程可以多次进入被锁保护的代码块。
不可重入锁:
不支持同一线程多次获取锁:不可重入锁不允许同一线程多次获取同一把锁。如果同一线程试图再次获取锁,它将被阻塞,可能导致死锁。
Linux系统的mutex:在Linux系统中,mutex是一种不可重入锁。如果同一线程尝试再次获取mutex锁,它会被阻塞,直到锁被释放。
总的来说,可重入锁允许同一线程多次获取锁,这在某些情况下非常方便,特别是在递归函数中。不可重入锁不支持同一线程多次获取锁,因此在使用时需要格外注意避免死锁等问题。在Java中,通常使用可重入锁的情况更常见。
当很多线程去尝试加一把锁的时候,一个线程能够拿到锁,其他线程阻塞等待。一旦第一个线程释放锁之后,接下来哪个线程能够拿到锁呢?
公平锁是根据先来后到的原则;
非公平锁是剩下的的锁都均等。
公平锁(Fair Lock)和非公平锁(Unfair Lock)是两种不同的锁获取策略,它们影响了多个线程争夺锁时的顺序和公平性。
公平锁(Fair Lock):
公平性:公平锁的获取策略是按照请求锁的顺序来分配锁,即按照先来先服务的原则。当多个线程等待锁时,锁会按照等待的顺序依次分配给等待的线程。这确保了所有线程都有公平的机会获得锁,避免了饥饿现象。
实现:在公平锁的实现中,锁会维护一个队列,每个线程按照请求锁的顺序加入队列,并在队列中等待锁的释放。当锁被释放时,会从队列中选择下一个等待的线程来获取锁。
优点:公平锁确保了锁的公平性,适用于需要公平竞争锁的情况,可以避免某些线程一直获取到锁而其他线程无法获得的情况。
非公平锁(Unfair Lock):
非公平性:非公平锁的获取策略是不考虑等待线程的顺序,它允许后来的线程插队,有可能导致先来的线程一直获取不到锁,不具备公平性。
实现:在非公平锁的实现中,线程可以在尝试获取锁时直接获取,而不必等待锁的释放。这有助于提高吞吐量,因为它减少了线程切换和竞争的开销。
优点:非公平锁在某些情况下可以提高性能,特别是在锁的竞争不激烈、锁的持有时间较短的情况下。它可以减少线程等待的时间。
选择公平锁还是非公平锁取决于应用的需求。如果公平性对于应用很重要,确保所有线程都有公平的机会获得锁,那么可以选择公平锁。如果性能更加关键,而公平性不是首要考虑因素,那么可以选择非公平锁以提高吞吐量。
在Java中,ReentrantLock提供了公平锁和非公平锁的选择,通过构造函数的参数来指定。默认情况下,ReentrantLock是非公平锁。
操作系统提供的加锁 API 默认情况下就属于“非公平锁”,如果想要实现公平锁,我们还需要引入额外的队列,维护这些线程的枷锁顺序。
悲观锁认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加 锁。
乐观锁认为多个线程访问同一个共享变量冲突的概率不大。并不会真的加锁,而是直接尝试访问数 据。 在访问的同时识别当前的数据是否出现访问冲突。
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据。获取不到锁就等待。
乐观锁的实现可以引入一个版本号。借助版本号识别出当前的数据访问是否冲突。 (实现细节参考上 面的图)。
悲观锁:
加锁:悲观锁认为多个线程访问共享资源可能会引发冲突,所以在访问共享资源之前,先要加锁。这通常通过操作系统提供的互斥锁(如mutex)来实现。只有获取到锁的线程才能继续访问共享资源,其他线程需要等待锁的释放。
操作数据:一旦线程获得锁,它就可以安全地对共享资源进行读取或写入操作。其他线程需要等待当前线程释放锁后才能访问共享资源。
释放锁:在完成对共享资源的操作后,线程需要释放锁,让其他线程有机会获取锁并访问共享资源。
悲观锁的实现比较简单,但它会引入一定的性能开销,因为多个线程需要等待锁的释放,可能会导致一些线程长时间等待。
乐观锁:
版本号或时间戳:乐观锁不会立即加锁,而是在访问共享资源时,会读取一个版本号或时间戳等标识,记录当前数据的状态。
尝试操作数据:线程尝试对共享资源进行操作,但在操作之前不会加锁。它会读取当前的版本号或时间戳,并记录下来。
检查冲突:在完成对共享资源的操作后,线程会再次读取共享资源的版本号或时间戳,并与之前记录的值进行比较。如果两者不一致,说明在操作期间有其他线程修改了共享资源,产生了冲突。
处理冲突:如果发现冲突,线程可以选择放弃操作、重试操作,或者采取其他策略来处理冲突。一种常见的处理方式是使用循环重试,直到操作成功或达到一定的重试次数。
乐观锁避免了加锁的性能开销,但需要更复杂的处理来处理冲突。版本号或时间戳通常用于标识数据的状态,以便在多个线程访问同一资源时检测到冲突。
总的来说,选择使用悲观锁还是乐观锁取决于应用场景和性能要求。悲观锁适用于冲突概率较高的情况,而乐观锁适用于冲突概率较低且需要高并发性能的情况。
读写锁(Read-Write Lock)读写锁就是把读操作和写操作分别进行加锁,它是一种用于多线程编程的锁机制,它的设计目的是在某些情况下提高并发性能。读写锁允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。这样,在读多写少的场景下,可以提高并发性能,因为多个线程可以同时读取数据而不互斥,而写操作会阻塞其他读写操作。
读写锁的基本特性如下:
读锁和读锁之间不互斥:多个线程可以同时获得读锁,允许并发读取共享资源。
写锁和写锁之间互斥:只有一个线程可以获得写锁,用于保护写操作,确保写入共享资源的操作是互斥的。
写锁和读锁之间互斥:当一个线程持有写锁时,其他线程不能获得读锁,以确保写操作和读操作之间的互斥性。
读写锁的应用场景通常是在读操作频繁,而写操作较少的情况下,以提高并发性能。读操作可以同时进行,而写操作会等待所有的读操作完成后才能执行,以保证写操作的一致性。
读写锁的实现可以采用不同的机制,如pthread中的pthread_rwlock_t,Java中的ReentrantReadWriteLock等。程序员需要根据具体的编程语言和库来使用和管理读写锁,以确保线程安全和性能优化。
如果获取锁失败,立即再尝试获取锁, 无限循环, 直到获取到锁为止。
第一次获取锁失败, 第二次的尝 试会在极短的时间内到来,一旦锁被其他线程释放, 就能第一时间获取到锁。
相比于挂起等待锁,
优点:
没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效
在锁持有时间比较短的场景下非常有用
缺点:
如果锁的持有时间较长, 就会浪费 CPU 资源
自旋锁(Spin Lock)是一种锁的实现方式,与传统的互斥锁(如mutex)不同,自旋锁不会让线程进入休眠状态等待锁的释放,而是在获取锁失败时,线程会一直尝试获取锁,不断地在一个循环中“自旋”,直到成功获取锁为止。自旋锁是一种忙等待的锁策略。
自旋锁的优点和使用场景:
优点:
没有放弃 CPU 资源:自旋锁不会导致线程进入休眠状态,因此线程不会让出 CPU 资源,这可以在一些情况下提高锁的竞争性能,尤其是当锁的持有时间非常短的时候。
响应时间低:一旦锁被其他线程释放,自旋锁的等待线程可以立即获得锁,因此响应时间非常低。
适用于短暂竞争:自旋锁适用于锁的竞争情况较短暂的场景,因为长时间自旋会浪费大量的 CPU 资源。
缺点:
CPU 资源浪费:自旋锁在获取锁失败时会不断地尝试获取锁,如果锁的竞争激烈或者持有锁的时间很长,那么自旋锁会浪费大量的 CPU 资源,降低系统的整体性能。
不适用于长时间持有锁:自旋锁不适用于长时间持有锁的情况,因为它会导致其他线程在自旋期间无法进入临界区,降低了并发性能。
不适用于多核心处理器:在多核心处理器上,自旋锁可能会导致多个线程在不同核心上不断自旋,消耗大量的总体 CPU 资源,降低了系统的效率。
因此,自旋锁适用于锁的竞争时间较短,且并发程度不高的情况。在长时间竞争或多核处理器上,应当谨慎使用自旋锁,并考虑使用其他锁策略,如互斥锁或读写锁,以更好地平衡性能和资源消耗。
synchronized 是可重入锁(也称为递归锁)。可重入锁允许同一个线程在持有锁的情况下多次进入被锁保护的代码块,而不会导致死锁。
Synchronized 实现可重入锁的方式是通过为每个锁关联一个线程标识以及一个计数器。当一个线程首次获取锁时,标识该线程为锁的持有者,并将计数器设置为1。当同一个线程再次尝试获取同一个锁时,会发现标识与当前线程一致,此时只会将计数器递增,而不会阻塞自己。只有当计数器变为0时,锁才会完全释放,其他线程才有机会获取锁。
这种可重入的特性允许开发者编写更加灵活和复杂的代码,例如在一个方法内部调用另一个加锁的方法,而不必担心死锁或锁的竞争问题。
synchronized属于哪种锁呢?
对于“悲观乐观”,自适应的;
对于“重量轻量”,自适应的;
对于“自选?挂起等待”,自适应的。
初始情况下,synchronized 会预测当前的锁冲突概率不大,此时以乐观锁的模式运行(此时就是轻量级锁,基于自旋锁的方式实现)。
在实际使用过程中,如果发现锁冲突的情况较多,会升级成悲观锁(此时也就是重量级锁,基于挂起等待的方式实现)。
"自适应" 是一个通用的概念,用于描述一种系统或机制具备根据环境或条件的变化来自动调整其行为的能力。在计算机科学和工程领域中,"自适应" 指的是系统、算法或机制可以根据运行时的情况和数据来调整其工作方式,以达到更好的性能、效率、或其他目标。
在锁或并发控制的上下文中,"自适应" 通常意味着锁系统能够根据线程的竞争情况、等待时间、负载等因素来动态选择合适的锁策略,以优化性能。例如,自适应锁可能会在锁竞争不激烈时选择一种轻量级的锁策略,而在锁竞争激烈时切换到一种更重的锁策略,以减少争用和上下文切换。
"自适应" 的概念在计算机科学中有广泛的应用,包括自适应算法、自适应性网络、自适应性操作系统等领域。它有助于系统更好地适应变化的需求和条件,以提高性能、资源利用率和用户体验。
CAS(Compare and Swap)是一种多线程同步的机制,通常用于实现无锁算法,特别是在多线程环境下对共享变量进行操作的情况下。
比较交换的是内存和寄存器:
交换的本质就是为了把B赋值给M。(寄存器B里面是啥我们不关心,关系的是M的情况)
CAS 操作包含了三个步骤:
比较(Compare):CAS首先比较内存中的某个值与一个预期值是否相等。这个预期值通常是当前共享变量的值。
交换(Swap):如果预期值与内存中的值相等,那么CAS会将共享变量的值替换为一个新的值。
返回(Return):CAS会返回操作是否成功,通常是一个布尔值。如果操作成功,表示原来的预期值与内存中的值相等,CAS已经成功将新值写入了共享变量。如果操作失败,表示有其他线程在这之前修改了共享变量的值。
CAS 是一种无锁的同步机制,因为它不涉及线程的挂起和恢复。它允许多个线程同时尝试更新共享变量,但只有一个线程会成功。其他线程会不断重试,直到成功为止。这种机制使得CAS在一些高并发情况下表现出色,因为它减少了锁竞争的开销。
CAS的经典应用包括实现自旋锁、原子计数器、非阻塞数据结构等。然而,CAS也有一些局限性,例如ABA问题(一个值从A变成B再变成A,在这期间可能有其他操作),以及无法解决某些复杂的并发问题。为了解决这些问题,通常需要结合其他同步机制,如锁或版本号等。
下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的。这个伪代码只是辅助理解 CAS 的工作流程,不能真正的编译执行,只是让我们能够认识到逻辑:
- boolean CAS(address, expectValue, swapValue) {
- if (&address == expectedValue) {
- &address = swapValue;
- return true;
- }
- return false;
- }
CAS其实是一个CPU指令,一个指令就能够完成上述比较交换的逻辑。由此推导出单个CPU指令是原子的!我们就可以使用CAS完成一些操作,进一步替代“加锁”。 这就给编写线程安全的代码引入了新的思路。基于CAS实现的线程安全的方式也称为“无锁编程”。
优点:保证线程安全,同时避免阻塞(效率)
缺点:代码复杂不好理解,只能更适合一些特定场所,不如加锁方式更普适。
CPU指令,又被操作系统封装,提供成API,又被JVM封装,提供成API。即可使用。
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的. 典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
- AtomicInteger atomicInteger = new AtomicInteger(0); // 相当于 i++
-
- atomicInteger.getAndIncrement();
- public class Demo {
- public static int count = 0;
-
- public static void main(String[] args) throws InterruptedException {
- Thread t1 = new Thread(() -> {
- for (int i = 0; i < 50000; i++) {
- count++;
- }
- });
- Thread t2 = new Thread(() -> {
- for (int i = 0; i < 50000; i++) {
- count++;
- }
- });
- t1.start();
- ;
- t2.start();
- t1.join();
- t2.join();
- System.out.println(count);
- }
- }
用了原子类:
- import java.util.concurrent.atomic.AtomicInteger;
-
- public class Demo {
- public static AtomicInteger count = new AtomicInteger(0);
-
- public static void main(String[] args) throws InterruptedException {
- Thread t1 = new Thread(() -> {
- for (int i = 0; i < 50000; i++) {
- //count++;
- count.getAndIncrement();
- //++count
- count.incrementAndGet();
- //count--
- count.getAndDecrement();
- //--count
- count.decrementAndGet();
- }
- });
- Thread t2 = new Thread(() -> {
- for (int i = 0; i < 50000; i++) {
- //count++;
- count.getAndIncrement();
- //++count
- count.incrementAndGet();
- //count--
- count.getAndDecrement();
- //--count
- count.decrementAndGet();
- }
- });
- t1.start();
- ;
- t2.start();
- t1.join();
- t2.join();
- System.out.println(count.get());
- }
- }
伪代码实现:
- class AtomicInteger {
- private int value;
- public int getAndIncrement() {
- int oldValue = value;
- while ( CAS(value, oldValue, oldValue+1) != true) {
- oldValue = value;
- }
- return oldValue;
- }
- }
通过形如上述代码就可以实现一个原子类。
不需要使用重量级锁, 就可以高效的完成多线程的自增操作。
本来 check and set 这样的操作在代码角度不是原子的,但是在硬件层面上可以让一条指令完成这 个操作, 也就变成原子的了。
在Java中,有些操作是偏底层的操作,在使用的时候有很多注意事项。
稍有不慎就容易出问题。这些操作就放到 unsafe 中进行归类。
反编译:
native修饰的方法就是“本地方法”,在JVM源码中使用了C++实现的逻辑。涉及到一些底层操作。
结论:原子类里面是基于CAS来实现的。
基于 CAS 实现更灵活的锁, 获取到更多的控制权
自旋锁伪代码
- public class SpinLock {
- private Thread owner = null;
- public void lock(){
- // 通过 CAS 看当前锁是否被某个线程持有.
- // 如果这个锁已经被别的线程持有, 那么就自旋等待.
- // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
- while(!CAS(this.owner, null, Thread.currentThread())){
- }
- }
- public void unlock (){
- this.owner = null;
- }
- }
缺点是cpu开销大,效率低。while循环相当于一直在忙等,锁冲突激烈的话还是不要用了。
CAS还有其他很多应用,是多线程中一种重要技巧。
虽然开发中我们直接使用CAS概率不大,但是还是经常会用到一些内部封装了CAS的操作。
什么是 ABA 问题?
即CAS进行操作的关键就是通过“值”没有发生变化来作为“没有其他线程穿插执行”判定依据。
但是这种判定方式不够严谨,再更极端的情况下可能有另一个线程穿插进来,把A-->B-->A;
针对第一个线程来说,看起来好像是这个值,没有变化,但是实际上已经被穿插执行过了。
所以ABA问题是一种在使用CAS(Compare-and-Swap)操作时可能出现的并发问题,其特点是虽然共享变量的值在操作之间发生了多次变化,但在最终比较时,值又回到了最初的状态,导致CAS操作误认为没有发生其他线程的干扰。这可能导致CAS操作不正确地成功。
ABA问题如果真的出现了,其实大部分情况下也不会产生BUG,值毕竟已经改回去了,逻辑上也不一定会产生BUG。
但是在极端情况下,这就不好说了……
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的。但是不排除一些特殊情况。
ABA问题通常在大多数情况下不会引发问题,因为大多数情况下,线程t1执行CAS操作时只关心共享变量的当前值是否等于预期值,而不关心在操作之间共享变量是否经历了其他值的变化。因此,线程t1可能会误认为CAS操作成功,尽管实际上在中间有其他线程的操作。
内存泄漏:如果共享变量是引用类型,线程t1可能会执行一些与共享变量相关的操作,而在ABA问题之后,共享变量的引用可能已经被其他线程修改。这可能导致共享变量引用的对象无法被垃圾回收,从而导致内存泄漏。
逻辑错误:在某些应用中,共享变量的值可能表示状态或计数,而不仅仅是引用。如果线程t1在ABA问题后不慎执行了不正确的逻辑,可能导致应用程序的不一致状态或错误计数。
举个例子,当涉及ABA问题时,一个常见的示例是使用CAS操作来处理账户余额,但是在多线程环境下可能出现问题。
场景: 某个在线购物平台的用户账户余额。
初始状态: 用户账户余额为1000元。
并发购物操作: 多个线程同时尝试购物,购物金额为100元。
期望行为: 我们期望每次购物操作只扣除100元,当余额不足时购物操作应该失败。
异常的过程:
这个例子中,虽然用户的账户余额发生了多次变化(从1000元到900元,然后到1400元,最后到1300元),但在线程2执行CAS操作时,它可能会错误地认为余额是1000元,因为它并不关心中间的值变化,这就是ABA问题导致的错误。
这个示例说明了在处理金融交易或账户余额等情况下,ABA问题可能导致不一致的结果,因为CAS操作无法捕获到中间值的变化。为了解决这种问题,通常需要引入版本号或其他附加信息,以便更可靠地检测到这种情况。
只要让判定的数值按照一个方向增长即可,不要反复横跳~
解决ABA问题的常见方法是引入一个额外的变量,版本号或标记来跟踪共享变量的变化。每次修改共享变量时,都会增加一个版本号或标记。这样,CAS操作不仅比较值,还比较版本号或标记,从而可以检测到ABA问题。如果版本号或标记不匹配,CAS操作将失败,即使值看起来相同。
使用版本号或标记的方式可以让CAS操作不仅比较值,还比较版本号或标记,如果版本号没变,注定没有线程穿插执行,从而防止了ABA问题。
具体步骤如下:
在共享变量中引入一个版本号或标记,初始为1或其他适当的值。
在执行CAS操作之前,读取共享变量的当前值和版本号。
在CAS操作中,除了比较值之外,还比较版本号是否与读取的版本号匹配。
如果值和版本号都匹配,执行修改操作,并将版本号递增。
如果版本号不匹配(即中间发生了其他修改),CAS操作失败,需要进行适当的重试或处理。
这种方式确保了CAS操作在检查值的同时也检查了版本号,从而可以检测到ABA问题。只有在值和版本号都匹配的情况下,CAS操作才会成功。
还是刚刚那个例子吗?
初始状态:我的存款为100,版本号为1。
并发取款操作:两个线程同时尝试取款50元,每个线程都读取存款值为100,版本号为1,然后尝试将存款值更新为50。
线程1成功执行取款操作,将存款值更新为50,同时版本号从1增加到2。线程2阻塞等待。
在线程2执行之前,滑稽的朋友向滑稽老哥转账50元,这使得账户余额变为100,版本号变为3。
线程2执行,它读取存款值为100,发现与之前读取的100相同,但版本号不同。之前读取的版本号为1,而当前版本号为3,版本号不匹配,线程2认为操作失败,从而避免了重复的取款操作。
总之,这种技术在并发编程中非常常见,被广泛用于实现各种数据结构和算法,以确保线程安全性和数据一致性。
在Java中,java.util.concurrent.atomic包中的原子类通常会使用类似的方式来解决ABA问题。例如,AtomicStampedReference类可以存储一个引用和一个整数标记,以便在CAS操作中同时比较引用和标记,从而防止ABA问题的发生。
在实际开发中,我们一般不会直接使用CAS,都是用封装好的。
CAS(Compare and Swap)是一种并发编程中的原子操作,其主要思想是通过比较内存中的值与一个期望值,如果它们相等,就将新值写入内存。CAS操作可以在不使用锁的情况下实现多线程间的数据同步和互斥访问。以下是对CAS机制的详细解释:
原子性操作:CAS是一种原子性操作,它是不可分割的,即在执行CAS操作时,不会被其他线程中断或修改。
三个基本参数:CAS操作需要三个基本参数:
CAS操作步骤:
CAS的原理:CAS操作在底层需要硬件的支持。通常,CPU提供了特定的CAS指令,使得CAS操作可以在一条机器指令中完成。这确保了CAS的原子性,因为其他线程无法在CAS操作的中间阶段插入操作。
应用场景:CAS主要用于解决多线程环境下的竞争问题,如锁、计数器、数据结构等。它可以提供更高的并发性,因为它不需要线程阻塞等待锁的释放。
CAS的缺点:
总之,CAS是一种强大的并发编程工具,它允许多个线程以原子方式读取和更新共享数据,而不需要显式的锁定。它在性能上通常比传统锁机制更有优势,但需要谨慎处理ABA问题和自旋等待的情况。
解决ABA问题的常见方法是引入版本号或标记,以确保CAS操作不仅比较值,还比较版本号或标记,从而更可靠地检测到中间值的变化:
这种方式确保了CAS操作在检查值的同时也检查了版本号或标记,从而可以检测到ABA问题。只有在值和版本号或标记都匹配的情况下,CAS操作才会成功。这种技术在并发编程中非常常见,特别是在需要确保数据一致性和正确性的高并发环境中。例如,Java中的AtomicStampedReference和AtomicMarkableReference就是用来解决ABA问题的原子类,它们同时比较引用和版本号或标记,从而提供更可靠的CAS操作。
结合上面的锁策略,我们就可以总结出Synchronized 具有以下特性(只考虑 JDK 1.8):
1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁
2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁。
3. 实现轻量级锁的时候大概率用到的自旋锁策略
4. 是一种不公平锁
5. 是一种可重入锁
6. 不是读写锁
JVM 将 synchronized 锁分为无锁、偏向锁、轻量级锁、重量级锁状态。
会根据情况,进行依次升级。锁升级的过程是单向的,不能再降级了!
第一个尝试加锁的线程,优先进入偏向锁状态。
偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程。
如果后续没有其他线程来竞争该锁,那么就不用进行其他同步操作了(避免了加锁解锁的开销)。
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别,当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态。
偏向锁本质上相当于 "延迟加锁" ,是“懒汉模式”的另一种体现,咱能不加锁就不加锁, 尽量来避免不必要的加锁开销。 但是该做的标记还是得做的, 否则无法区分何时需要真正加锁。
让我用一个图书馆的图书借阅场景来进一步解释偏向锁:
图书馆的图书是锁:假设我们有一个小图书馆,里面有一本非常受欢迎的书籍,多个读者可以借阅这本书,这本书就相当于一个锁对象。
第一个借阅者是小明:一开始,小明是第一个尝试借阅这本书的读者,那么这本书可以被标记为偏向于小明,因为只有他借阅它。
偏向锁不是真的借书:偏向锁的引入并不是真的“借书”,而是在书的封面上做一个“偏向锁的标记”,用来记录这本书属于哪个读者(小明)。
避免不必要的借书还书开销:由于一开始只有小明借阅这本书,其他读者不需要进行借书还书操作,避免了不必要的开销。
其他读者尝试借书:如果后续有其他读者(比如小红)也尝试借阅这本书,偏向锁会被取消,因为多个读者开始竞争借阅这本书。
小明的坚决借书决心:如果小红尝试竞争借阅,那么不管这本书的借阅手续多么繁琐,小明也必须完成这个操作,以确保抢先小红借阅到这本书。
总之,偏向锁适用于单线程或低竞争情况下的锁性能优化,避免了不必要的加锁解锁开销。但如果多个线程竞争这个锁,偏向锁会自动取消,以确保多线程之间的公平竞争。这个机制有助于提高Java应用程序中锁的性能。
随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态,这也是一种自适应的自旋锁。轻量级锁通过CAS(比较并交换)来实现:
CAS检查和更新内存:当一个线程尝试获取轻量级锁时,它会使用CAS操作来检查并更新一块内存,通常是对象头中的一部分。这块内存用于存储锁的信息,例如指向持有锁的线程的引用或标记。
CAS操作成功:如果CAS操作成功,意味着该线程成功获取了锁,它认为加锁成功。在这种情况下,该线程可以继续执行临界区的代码。
CAS操作失败:如果CAS操作失败,意味着锁已经被其他线程持有,当前线程无法立即获取锁。此时,当前线程不会放弃CPU,而是进入自旋状态,继续尝试获取锁。
自旋等待:自旋是一种忙等待的机制,线程在这里不会进入阻塞状态,而是反复检查锁的状态,看是否能够获取锁。这样可以避免线程进入和退出阻塞状态的开销,但也会浪费CPU资源。
自适应自旋:轻量级锁通常不会一直持续自旋等待,而是采用自适应策略。也就是说,线程会自行调整自旋等待的时间或重试次数,如果在一定时间内或重试次数达到上限仍然无法获取锁,线程会将自旋等待转化为正常的阻塞等待。
总结而言,轻量级锁通过CAS操作实现了一种自适应的自旋锁机制,它可以在一定程度上减少线程进入和退出阻塞状态的开销,提高了锁的性能。然而,自旋锁也需要注意控制自旋等待的时间和次数,以免浪费CPU资源。如果自旋等待时间过长或自旋次数过多,还是会影响性能。因此,它是一种权衡,适用于竞争不激烈的情况。
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁。
重量级锁是一种用于处理高度竞争的锁情况的机制:
竞争激烈:当多个线程竞争同一个锁时,且竞争非常激烈,轻量级锁中的自旋等待可能无法快速获得锁状态。
使用内核提供的mutex:在这种情况下,锁会升级为重量级锁,这意味着它将依赖于操作系统提供的内核级别的mutex(互斥量)。
进入内核态:在执行加锁操作时,线程首先会进入内核态,这是一个高权限的状态,允许线程执行一些操作系统提供的底层操作。
内核判断锁状态:在内核态中,操作系统会判定当前锁是否已经被占用。如果锁没有被占用,那么线程的加锁操作会成功,然后线程将切换回用户态,继续执行。
锁被占用:如果锁已经被其他线程占用,那么加锁操作将失败。在这种情况下,线程将进入锁的等待队列并挂起。线程会等待操作系统的唤醒信号。
等待和唤醒:线程在等待队列中挂起,直到锁被其他线程释放。当其他线程释放锁并希望使用锁的时候,操作系统会唤醒等待队列中的一个或多个线程,尝试重新获取锁。
总结而言,重量级锁是一种处理高度竞争的锁情况的机制,它依赖于操作系统提供的内核级别的mutex来实现锁的管理。虽然重量级锁能够处理激烈的竞争,但是由于涉及到内核态和用户态之间的切换,它的性能开销通常比轻量级锁要大。因此,重量级锁适用于高竞争的场景,但在竞争不激烈的情况下,轻量级锁更为高效。
锁升级的过程就是在性能和线程安全直接尽量进行权衡。 不同的锁级别(偏向锁、轻量级锁、重量级锁)在性能和线程安全方面有不同的权衡考虑。锁升级的过程就是根据竞争情况动态地切换锁的级别,以在不同场景下平衡性能和线程安全。在多线程编程中,选择合适的锁级别对于系统的性能和可伸缩性至关重要。因此,开发人员需要根据具体的应用场景和并发情况来选择和优化锁的级别,以达到最佳的性能和线程安全。
编译器+JVM 判断锁是否可消除。 如果可以, 就直接消除。
什么是 "锁消除" ?
锁消除也是编译器和JVM在代码执行过程中的一种优化技术,编译器会自动针对你当前写的加锁的代码进行判定,如果它觉得这个场景不需要加锁,此时就会把你写的synchronized给优化掉。
所以它用于判断某些同步锁是否可以被消除。锁消除的目标是提高程序的性能,特别是在没有多线程竞争的情况下,避免不必要的锁开销。
比如说,StringBuilder不带synchronized,StringBuffer带synchronized。不是说你写了synchronized就一定线程安全!
如果是在单个线程中使用StringBuffer,编译器就会自动把synchronized给优化掉!
锁消除的基本思想是在运行时检测代码中的锁,如果编译器和JVM发现某个锁在整个代码执行过程中不会被多个线程竞争,那么它们就会认为这个锁是安全的,并将其消除。这意味着在这种情况下,同步块内的代码可以在没有锁的情况下执行,从而提高程序的性能。
当然,编译器只会在自己非常有把握的时候才会进行锁消除,触发概率不是很高。消除锁是编译阶段触发的,还没到运行时呢!
而对比偏向锁,它则是在运行的时候根据多线程的调度情况的不同来决定的。编译阶段无法判定这个锁是否必要,就只会保守的保留锁。
刚刚提过,一个典型的例子是使用了StringBuffer或StringBuilder的情况。这些类通常包含了同步的方法,如append方法。但是如果在代码中只有单线程在使用它们,编译器和JVM可以检测到没有多线程竞争,因此可以消除掉这些不必要的同步锁,从而提高字符串操作的性能。
- StringBuffer sb = new StringBuffer();
- sb.append("a");
- sb.append("b");
- sb.append("c");
- sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁。 但如果只是在单线程中执行这个代码, 那么这些加 锁解锁操作是没有必要的, 白白浪费了一些资源开销。
需要注意的是,锁消除是由编译器和JVM自动进行的优化,而不是由程序员显式控制的。它通常在即时编译(Just-In-Time Compilation)阶段完成(没到运行的时候),程序员只需编写线程安全的代码,而不需要手动指定锁是否消除。然而,了解锁消除的概念有助于理解性能优化和代码执行过程中的一些细节。
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化。
锁粗化是一种针对锁的优化策略,它可以减少锁的申请和释放次数,提高程序的性能:
锁的粒度:锁的粒度可以分为粗粒度和细粒度。synchronized里面,代码越多,就认为锁的粒度越粗。反之,锁的粒度越细。粗粒度锁是在一个大的代码块上加锁,而细粒度锁是在多个小的代码块上加锁。
多次加锁解锁:在一段逻辑中如果出现多次加锁和解锁操作,例如多次进入临界区并退出,这会导致频繁的锁申请和释放,增加了锁的开销。粒度细的时候能够并发执行的逻辑越多,更有利于充分利用多核CPU资源。但是如果粒度细的锁被反复进行加锁解锁,可能实际效果还不如力度粗的锁(涉及到反复的锁竞争)。
锁粗化的优化:为了减少频繁的锁申请和释放,编译器和JVM可以自动进行锁的粗化优化。这意味着它们会将多个连续的锁操作合并成一个大的锁操作。
避免频繁申请释放锁:锁粗化的目标是避免频繁的申请和释放锁,因为这些操作会增加系统的开销。通过将多个锁操作合并成一个大的锁操作,可以减少锁操作的次数。
粗化策略:锁粗化的策略是在发现连续的加锁解锁操作时,将它们合并成一个大的锁操作。这个大的锁操作覆盖了整个连续代码块,避免了多次加锁解锁的开销。
锁粗化是一种优化策略,用于减少锁操作的次数,提高程序性能。它通过将多次加锁解锁操作合并成一个大的锁操作,避免了频繁的锁申请和释放,特别适用于那些需要频繁进入临界区的情况。在实际开发中,使用细粒度锁是期望能够释放锁以便其他线程使用,但是如果没有其他线程来竞争锁,JVM可能会自动进行锁粗化优化,以避免频繁的加锁解锁操作。
举个例子看看:
假设有一个电商网站,多个用户同时访问该网站的购物车功能,每个用户都有一个独立的购物车对象。在购物车功能中,用户可以往购物车中添加商品,删除商品,以及结算购物车中的商品。
最初的实现是使用了细粒度锁,即每个用户的购物车对象都有一个独立的锁,用于保护购物车的操作。这样,每个用户在访问自己的购物车时都需要获取自己购物车的锁,进行加锁和解锁操作。
- public class ShoppingCart {
- private List<String> items = new ArrayList<>();
- private final Object lock = new Object();
-
- public void addItem(String item) {
- synchronized (lock) {
- items.add(item);
- }
- }
-
- public void removeItem(String item) {
- synchronized (lock) {
- items.remove(item);
- }
- }
-
- public void checkout() {
- synchronized (lock) {
- // 执行结算操作
- }
- }
- }
在这个实现中,每个用户在对自己的购物车进行操作时都需要获取自己购物车的锁,这是一种细粒度锁的设计。
然而,如果用户数量非常多,每个用户都在独立的购物车上进行操作,那么加锁和解锁操作的开销会很大,因为每个用户都要频繁地获取和释放锁。
在这种情况下,编译器和JVM可以进行锁粗化优化。它们可以将多个用户的购物车操作合并成一个大的锁操作,即在整个购物车操作过程中只获取一次锁,然后执行完所有操作后再释放锁。
在这个优化后的实现中,购物车的操作在整个方法内部都使用了同一个锁,避免了频繁的锁操作,提高了性能。
并发(这个包里面的内容多是一些多线程相关的组件,除非显式说明,都不用背,有个大概的印象就可以了~)
Callable 是一个 interface 。 相当于把线程封装了一个 "返回值"。方便我们借助多线程的方式计算结果。
它也是一种创建线程的方式。适合于“想让某个线程执行一个逻辑,并返回结果”。相比之下,Runnable不关注结果。
总结来说就是,Callable 是 Java 中的一个接口,用于表示可以由多个线程并行执行的任务。它类似于 Runnable 接口,但有一个关键的不同点:Callable 任务可以返回一个结果或抛出异常。
代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000, 不使用 Callable 版本
- static class Result {
- public int sum = 0;
- public Object lock = new Object();
- }
- public static void main(String[] args) throws InterruptedException {
- Result result = new Result();
- Thread t = new Thread() {
- @Override
- public void run() {
- int sum = 0;
- for (int i = 1; i <= 1000; i++) {
- sum += i;
- }
- synchronized (result.lock) {
- result.sum = sum;
- result.lock.notify();
- }
- }
- };
- t.start();
- synchronized (result.lock) {
- while (result.sum == 0) {
- result.lock.wait();
- }
- System.out.println(result.sum);
- }
- }
可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错。
代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000, 使用 Callable 版本
- import java.util.concurrent.Callable;
- import java.util.concurrent.ExecutionException;
- import java.util.concurrent.FutureTask;
-
- public class Demo2 {
- public static void main(String[] args) throws ExecutionException, InterruptedException {
- //定义了任务
- Callable<Integer> callable = new Callable<Integer>() {
- @Override
- public Integer call() throws Exception {
- int sum = 0;
- for (int i = 1; i <= 1000; i++) {
- sum += i;
- }
- return sum;
- }
- };
-
- //把任务放到线程中执行
- FutureTask<Integer> futureTask = new FutureTask<>(callable);
- Thread t = new Thread(futureTask);
- t.start();
- //此处的 get 就能获取到callable里面的返回结果。
- //由于线程是并发执行的,执行到主线程的 get 的时候,t线程可能还没执行完
- //没执行完的话,get 就会阻塞
- int result = futureTask.get();
- System.out.println(result);
- }
-
- }
-
可以看到, 使用 Callable 和 FutureTask 之后, 代码简化了很多,我们也不必手动写线程同步代码了。
理解 Callable
Callable 和 Runnable 相对, . Callable 描述的是带有返回值的任务, Runnable 描述的是不带返回值的任务. Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为 Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定. FutureTask 就可以负责这个等待结果出来的工作.
Callable 和 Runnable 在功能上是相似的,它们都可以被 Thread 类用作目标对象,都是描述一个 "任务"。但是 Callable 具有返回值,而 Runnable 没有。Callable 的 call 方法带有返回值, 所以Callable 通常需要搭配 FutureTask 来使用, FutureTask 用来保存 Callable 的返回结果。 因为Callable 往往是在另一个线程中执行的, 啥时候执行完我们并不确定,此时 FutureTask 就可以负责这个等待结果出来的工作。
而 Runnable 的 run 方法则没有。
在 Java 并发编程中,当我们需要获取线程执行结果时,就会使用 Callable,它被设计成具有返回值的,并且可以抛出异常。
理解 FutureTask
FutureTask 是一个包装器,它接受 Callable 实例作为参数,并将其转换为一个 Runnable,因此它可以提交给 ExecutorService。当运行这个 FutureTask 时,它会在内部调用 Callable 的 call 方法,并保存其返回结果。之后,我们可以使用 FutureTask 的 get 方法来获取这个结果。
以麻辣烫为例:当你在餐厅点了麻辣烫后,厨师开始准备。在这个过程中,你并不直接与厨师进行交互来获取你的麻辣烫,而是得到一张小票。这张小票允许你在适当的时候检查麻辣烫是否已经准备好。在这里,Callable 就像是厨师,它的任务是准备麻辣烫;FutureTask 就像那张小票,允许你查询状态并最终获取麻辣烫(即 Callable 的结果)。
"ReentrantLock" 中的 "Reentrant" 表示可重入,这是指这种锁允许同一个线程多次获取同一个锁而不会造成死锁。这与 synchronized 关键字的语义类似,因为 synchronized 也是可重入的,它们都是用来实现互斥效果, 保证线程安全。
可重入锁的主要特点是,同一个线程可以多次获取同一个锁,而不会被自己阻塞。这允许线程在执行某个同步方法时,可以再次调用该方法,而不会被锁阻塞。这种特性对于复杂的程序结构或递归算法非常有用。
ReentrantLock 是 Java 标准库中提供的可重入锁的一种实现,它提供了比 synchronized 更灵活的锁定方式。
与 synchronized 不同,ReentrantLock 可以用于更复杂的锁定需求,例如可以指定锁的公平性、超时等待、可中断锁等特性。
lock(): 这是ReentrantLock最基本的加锁方法,它会一直等待直到获得锁。如果某个线程已经获得了锁,那么其他线程调用lock()会被阻塞,直到锁被释放。千万不要忘记 unlock( )的调用!!!
- ReentrantLock lock = new ReentrantLock();
-
- // 线程1获取锁
- lock.lock();
- try {
- // 执行线程1的代码
- // working
- } finally {
- // 确保锁会被释放
- lock.unlock();
- }
tryLock(): 这个方法尝试去获取锁,但是如果锁当前被其他线程占用,它不会一直等待,而是会立即返回一个结果,告诉你是否获取锁成功。你也可以传递一个超时时间作为参数,如果在超时时间内没有获取到锁,它会返回false。
- ReentrantLock lock = new ReentrantLock();
-
- if (lock.tryLock()) {
- try {
- // 执行加锁成功后的代码
- } finally {
- lock.unlock();
- }
- } else {
- // 未获取到锁,处理失败的情况
- }
unlock(): 这个方法用于释放锁。它必须在之前获取锁的线程中调用,以确保锁被正确释放。
使用ReentrantLock的好处之一是我们可以在try块中获取锁,然后在finally块中释放锁,这样可以确保无论在获取锁之后发生什么异常,锁都会被正确释放,避免死锁情况。
ReentrantLock还提供了其他方法,如lockInterruptibly()(可以响应中断)、isLocked()(检查是否被某个线程持有锁)等,这些方法增强了锁的灵活性,使其适用于更多不同的多线程场景。
实现方式:
手动释放锁:
等待策略:
公平性:
可中断性:
总的来说,ReentrantLock相比synchronized更加灵活,提供了更多的特性,如可中断性、公平性设置、超时等待等,适用于更复杂的多线程场景。由于其需要手动释放锁,需要谨慎使用,以避免忘记释放锁导致的死锁问题。在大多数情况下,使用synchronized已经足够满足多线程同步的需求,因为它简单、易用且性能表现良好。只有在需要更高级的特性时,才考虑使用ReentrantLock。
虽然ReentrantLock由上述优势,但是在加锁的时候我们还是首选synchronized。
另外synchronized背后还有一系列优化手段。
- // ReentrantLock 的构造方法
- public ReentrantLock(boolean fair) {
- sync = fair ? new FairSync() : new NonfairSync();
- }
更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一 个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指 定的线程
如何选择使用哪个锁?
选择使用哪个锁的决策可以根据锁竞争程度和需求来进行。
使用synchronized:
使用ReentrantLock :
选择锁的公平性:
最终的选择应该根据具体的应用场景和性能需求来确定。在编写多线程代码时,还要谨慎考虑锁的粒度、避免死锁和饥饿等问题,以确保多线程程序的正确性和性能。
原子类(Atomic Classes)是Java中用于支持多线程并发操作的一组类,它们内部使用了CAS(Compare-And-Swap)操作来实现原子性操作。这些类提供了一种可靠的方式来处理多线程并发访问共享变量,而无需显式使用锁。
以下是一些常见的原子类:
原子类通常用于替代锁来管理共享数据的访问,因为它们具有更低的性能开销。但需要注意,虽然原子类可以确保单个操作是原子性的,但不能解决复合操作的原子性问题,例如检查后操作(check-then-act)。
AtomicInteger 是用于原子性操作整数的类,它提供了一系列常见的方法来执行原子性操作。以 AtomicInteger为例,它的一些常见方法如下:
信号量就是用来表示可用资源的个数,它在多线程环境中提供了一种有效的控制和协调资源访问的机制。信号量的应用范围很广泛,它可以用来解决各种并发编程问题,如控制线程的数量,限制资源的访问,以及实现生产者-消费者模式等。
停车场的比喻是一个很好的方式来理解信号量的工作原理:
也就是,信号量本质上是一个计数器。
每次申请一个可用资源,就需要让计数器-1;
每次释放一个可用资源,就需要让计数器+1;(+1-1都是原子的)
在多线程编程中,信号量通常有两个主要操作:
P 操作(等待操作)(使用acquire): 当一个线程需要获取一个可用资源时,它会执行 P 操作。如果可用资源数大于 0,线程会成功获取资源,可用资源数减少;如果可用资源数已经为 0,线程将被阻塞等待,直到有其他线程释放资源。
V 操作(释放操作)(使用release): 当一个线程释放一个资源时,它会执行 V 操作。这会导致可用资源数增加。如果有其他线程在等待资源,它们中的一个将会被唤醒,成功获取资源。
在使用信号量时,通常是每次释放资源都需要调用一次 release 方法,释放一个资源。这意味着如果你申请了多个资源,就需要相应地多次调用 release 方法来释放这些资源。
例如,如果你在一个线程中申请了三个资源,那么在使用完这三个资源后,应该分别调用三次 release 方法来释放它们,以便让其他线程能够再次申请和使用这些资源。
每次调用 release 方法都会增加信号量的计数器,表示有一个额外的资源可用。其他线程可以通过调用 acquire 方法来申请这些资源。
你有没有觉得,这里的“阻塞等待”就有一种锁的感觉?
其实,锁本质上就属于一种特殊的信号量!所就是可用资源为 1 的信号量,加锁操作——P,1-->0;解锁操作——V,0--->1。你可以把它看作是一种二元信号量。
操作系统提供了信号量的实现,提供了API,JVM封装了这样的API,就可以在Java代码中使用了:
public Semaphore(int permits, boolean fair) 构造函数:
public Semaphore(int permits) 构造函数:
- public static void main(String[] args) throws InterruptedException {
- Semaphore semaphore = new Semaphore(4);
- semaphore.acquire();
- System.out.println("第一次P操作");
- semaphore.acquire();
- System.out.println("第二次P操作");
- semaphore.acquire();
- System.out.println("第三次P操作");
- semaphore.acquire();
- System.out.println("第四次P操作");
- semaphore.release();
- }
- import java.util.concurrent.Semaphore;
-
- class Demo{
-
- public static void main(String[] args) throws InterruptedException {
- Semaphore semaphore = new Semaphore(4);
- semaphore.acquire();
- System.out.println("第一次P操作");
- semaphore.acquire();
- System.out.println("第二次P操作");
- semaphore.acquire();
- System.out.println("第三次P操作");
- semaphore.acquire();
- System.out.println("第四次P操作");
- semaphore.acquire();
- System.out.println("第五次P操作,但是没有资源了,阻塞等待中……");
- semaphore.release();
- }
- }
- public static void main(String[] args) throws InterruptedException {
- Semaphore semaphore = new Semaphore(4);
- semaphore.acquire();
- System.out.println("第一次P操作");
- semaphore.acquire();
- System.out.println("第二次P操作");
- semaphore.acquire();
- System.out.println("第三次P操作");
- semaphore.acquire();
- System.out.println("第四次P操作");
- semaphore.release();
- semaphore.acquire();
- System.out.println("第五次P操作");
- }
你会不会有这样的疑问?
我都是 release了一个资源,为啥第一种写法不可以,还在阻塞?而第二种就可以?
第一次,release()方法只是简单地增加了可用许可证的数量,但不会自动唤醒等待的线程。其他线程需要再次调用acquire()方法来争夺许可证。这就是为什么在我的代码中,即使在调用semaphore.release()后,后续的semaphore.acquire()仍然会被阻塞,因为没有其他线程来竞争那个许可证。
换言之,整个代码被塞住了,其实我们都没有执行到semaphore.release()……
所以最好是在每次调用acquire之后在适当的时候调用release来释放许可证:
- Semaphore semaphore = new Semaphore(4);
- semaphore.acquire();
- System.out.println("第一次P操作");
- semaphore.release(); // 释放一个许可证
- semaphore.acquire();
- System.out.println("第二次P操作");
- semaphore.release(); // 释放一个许可证
- // 继续这个过程...
再举个例子:
- import java.util.concurrent.Semaphore;
-
- public class Demo4 {
- public static void main(String[] args) {
- Semaphore semaphore = new Semaphore(4);
- Runnable runnable = new Runnable() {
- @Override
- public void run() {
- try {
- System.out.println("申请资源");
- semaphore.acquire();
- System.out.println("我获取到资源了");
- Thread.sleep(1000);
- System.out.println("我释放资源了");
- semaphore.release();
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- };
- for (int i = 0; i < 20; i++) {
- Thread t = new Thread(runnable);
- t.start();
- }
- }
- }
在软件开发中,有时候需要控制多个线程或进程对共享资源的访问,以确保线程安全和资源的合理分配。这时,可以使用信号量来实现资源的申请和释放。
控制线程对临界区的访问:多个线程需要访问一个临界区,但只能有一个线程访问。一个信号量可以用来控制对该临界区的访问。
限制资源的并发访问:例如,一个数据库连接池中有多个数据库连接,每个线程需要从池中获取连接并使用它,然后释放连接。信号量可以用来限制并发获取连接的数量,以防止连接池被过度使用。
控制任务的并发执行:如果有一组任务需要并发执行,但你想限制同时执行的任务数量,可以使用信号量来控制任务的并发度。
CountDownLatch 是一个在多线程编程中常用的同步工具,其主要用途是在多个线程协作完成一系列任务时,用来判定任务的进度是否已经完成。
比如需要把一个大任务拆分成一个一个小的任务,让这些任务并发去执行。我们就可以使用CountDownLatch来判定说当前的这些任务是否已经完成。
比如说下载一个文件,就可以使用多线程下载。
很多的下载工具速度不快,相比之下有一些专业的下载工具就可以成倍的提高下载速度(IDM)。
大部分的下载工具和资源服务器只有一个链接,服务器往往会对于连接传输的速度有限制,而 IDM就是采取了一个多线程下载的方式,每个线程都建立一个连接。此时就需要把任务分割为小任务。
CountDownLatch 主要方法:
- import java.util.concurrent.CountDownLatch;
-
- public class Demo5 {
- public static void main(String[] args) throws InterruptedException {
- //当前有10个选手参数,await就会在10次调用完countDown之后才能继续执行
- CountDownLatch countDownLatch=new CountDownLatch(10);
- for(int i =0;i<10;i++) {
- int id = i;
- Thread t = new Thread(() -> {
- System.out.println("thread" + id);
- try {
- Thread.sleep(500);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- //通知说当前的任务都执行完毕
- countDownLatch.countDown();
- });
- t.start();
- }
- countDownLatch.await();
- System.out.println("所有的任务都执行完了!");
- }
- }
你会发现线程的执行顺序是不确定的。这是因为线程的调度和执行是由操作系统和JVM管理的,取决于多个因素,包括系统负载、线程调度策略等。因此,我们观察到的结果是无序的,不同次运行可能产生不同的线程执行顺序。
如果我们希望线程按照特定顺序执行,可以使用ExecutorService中的submit方法,将任务按照特定顺序提交给线程池执行。这样可以确保线程按照提交的顺序执行。
- import java.util.concurrent.CountDownLatch;
- import java.util.concurrent.ExecutorService;
- import java.util.concurrent.Executors;
-
- public class Demo6 {
-
- public static void main(String[] args) throws InterruptedException {
- ExecutorService executor = Executors.newFixedThreadPool(10);
- CountDownLatch countDownLatch = new CountDownLatch(10);
-
- for (int i = 0; i < 10; i++) {
- int id = i;
- executor.submit(() -> {
- System.out.println("thread " + id);
- try {
- Thread.sleep(500);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- countDownLatch.countDown();
- });
- }
-
- countDownLatch.await();
- System.out.println("所有的任务都执行完了!");
-
- executor.shutdown();
- }
- }
如果单单把 for 循环改为循环9次,那么会阻塞等待:
- import java.util.concurrent.CountDownLatch;
-
- public class Demo5 {
- public static void main(String[] args) throws InterruptedException {
- //当前有10个选手参数,await就会在10次调用完countDown之后才能继续执行
- CountDownLatch countDownLatch=new CountDownLatch(10);
- for(int i =0;i<9;i++) {
- int id = i;
- Thread t = new Thread(() -> {
- System.out.println("thread" + id);
- try {
- Thread.sleep(500);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- //通知说当前的任务都执行完毕
- countDownLatch.countDown();
- });
- t.start();
- }
- countDownLatch.await();
- System.out.println("所有的任务都执行完了!");
- }
- }
线程同步的方式有哪些?
线程同步是多线程编程中的一个重要概念,它指的是协调多个线程的执行顺序,以确保它们按照一定的规则和顺序访问共享资源,以避免数据竞争和不确定性的结果。
synchronized, ReentrantLock, Semaphore、CountDownLatch 等都可以用于线程同步。
原来的集合类, 大部分都不是线程安全的。
Vector, Stack, Hashtable, 是线程安全的(属于是上古时期Java引入的集合类了,现在不建议使用,快要被淘汰了), 其他的集合类不是线程安全的。
针对这些线程不安全的集合类,要想在多线程环境下使用,就需要考虑好线程安全问题了。
你第一个想到的肯定是加锁,这也是最常见的方法。
同时,标准库也给我们提供了一些搭配的组件来保证线程的安全。
List<String> synchronizedList = Collections.synchronizedList(new ArrayList<>());
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List。
synchronizedList 的关键操作上都带有 synchronized。
Collections.synchronizedList() 方法返回一个包装了原始List的新List,该新List的关键操作(如添加、删除、遍历等)都是通过synchronized关键字进行同步的,以确保多个线程可以安全地访问和修改列表。
简单来说就是这个方法会返回一个新的对象,这个新的对象就相当于给ArrayList套了一层壳,这层壳就是在方法上直接使用synchronized的。单线程下使用,可以就用本体,但是多线程下就可以套上外壳。
使用Collections.synchronizedList()有一些优点和限制:
优点:
限制:
如果对性能有更高要求或需要支持更复杂的操作,考虑使用java.util.concurrent包中的并发集合类,如CopyOnWriteArrayList或ConcurrentLinkedQueue,它们提供更好的性能和更丰富的功能。这些并发集合类通常使用更高级的同步策略来提供更好的并发性。
CopyOnWriteArrayList是Java中的一种并发容器,它的主要特点是在写操作时进行拷贝(复制)操作,以确保写操作不会影响正在进行的读操作,从而实现读写分离,从而提供了一种在特定场景下非常有用的线程安全机制。
比如,两个线程使用同一个ArrayList,可能会读,也可能会修改。如果两个线程要读,就直接读。但是如果某个线程需要进行修改,就要把ArrayList复制出一份副本。修改线程其实是在修改副本。与此同时,另一个线程仍可以读取数据(从原来的那份数据读)。一旦这边修改完毕,就会使用修改好的这份数据去替代掉原来的数据(往往就是一个引用赋值,极快的速度)。
上述的是个过程进行修改,就不需要加锁了。
以下是关于CopyOnWriteArrayList的一些优点和缺点:
优点:
缺点:
总之,CopyOnWriteArrayList是一种适用于读多写少场景的并发容器,它通过牺牲写操作的性能来提供读操作的高并发性。在合适的应用场景下,它可以是一种非常有用的工具,比如“服务器的配置更新”(可以通过配置文件来描述配置的详细内容。这个配置本身不会很大。配置的内容会被读到内存中,再由其他的线程读取这里的内容。但是修改这个配置内容往往只有一个线程来修改)。当然,我们也需要根据具体需求权衡其优缺点。如果写操作频繁或要求实时性较高,可能需要考虑其他并发容器或同步机制。
1) ArrayBlockingQueue 基于数组实现的阻塞队列
2) LinkedBlockingQueue 基于链表实现的阻塞队列
3) PriorityBlockingQueue 基于堆实现的带优先级的阻塞队列
4) TransferQueue 最多只包含一个元素的阻塞队列(不常用)
HashMap 本身不是线程安全的
在多线程环境下使用哈希表可以使用: Hashtable 和 ConcurrentHashMap。
Hashtable保证线程安全,主要就是给方法加上synchronized,直接加到方法上(相当于给this加锁)。也就是说,只要两个线程在操作同一个Hashtable,就会出现锁冲突。
这相当于直接针对 Hashtable 对象本身加锁:
但是实际上,对于哈希表来说,锁不一定非要这么加。有些情况其实是不涉及到线程安全问题的。
还记得哈希冲突吗?就是两个不同的key映射到了同一个数组的下标上,出现哈希冲突。
两个解决方案,重新找空闲位置(不常用,麻烦);使用链表来解决。
那么以使用链表为例,按照“在两条链表上修改元素并且不考虑触发扩容的前提下”,这个时候线程就是安全的。相比之下,如果两个线程操作的是同一个链表,才比较容易出问题!
综上,如果两个线程操作的是不同链表,就根本不许用加锁;只有操作的同一个链表才需要加锁(一个哈希表有很多链表,两个线程恰好同时访问同一个链表的情况本身就比较少)(锁通:用每个链表的头结点作为锁对象)。
ConcurrentHashMap最核心的改进就是把一个全局的HashMap的大锁,改成了每个链表一把独立的小锁,从而大幅度降低锁冲突的概率。
ConcurrentHashMap 的一些其他重要改进和重要实现细节:
ConcurrentHashMap 的基本使用方法和基本的HashMap完全一样
HashTable、HashMap 和 ConcurrentHashMap 都是在Java中用于存储键值对的数据结构,但它们在性能、线程安全性和用法上有一些关键区别:
1、线程安全性:
2、性能:
3、允许null键和值:
4、迭代器:
5、初始容量和负载因子:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。