Java多线程 (进阶) 锁策略和CAS面试题

点进来你就是我的人了
博主主页:戳一戳,欢迎大佬指点!

人生格言:当你的才华撑不起你的野心的时候,你就应该静下心来学习!

欢迎志同道合的朋友一起加油喔
目标梦想:进大厂,立志成为一个牛掰的Java程序猿,虽然现在还是一个嘿嘿
谢谢你这么帅气美丽还给我点赞!比个心


目录

1.乐观锁vs悲观锁

1.1 悲观锁

1.2 乐观锁

2.重量级锁vs轻量级锁

2.1 轻量级锁

2.2 重量级锁

3.自旋锁VS挂起等待锁

3.1 自旋锁

3.2 挂起等待锁

4.互斥锁VS读写锁

4.1 互斥锁

4.2 读写锁

5.可重入锁VS不可重入锁

5.1 可重入锁

5.2 不可重入锁

6.CAS

6.1 实现原子类:

6.2 实现自旋锁:

7.面试题,CAS的ABA问题怎么解决



1.乐观锁vs悲观锁

Java中的乐观锁和悲观锁是两种并发控制的策略,用于解决多线程访问共享资源时可能出现的竞争和冲突问题。

1.1 悲观锁

悲观锁的思想是,每次访问共享资源时都假定其他线程可能同时访问该资源,因此会对该资源进行加锁保护。在Java中,synchronized、ReentrantLock等内置锁就是悲观锁的实现。当一个线程获取到锁后,其他线程必须等待锁的释放才能访问该资源,从而保证了资源的独占性和数据的一致性。悲观锁的缺点是,对于高并发场景,频繁地获取和释放锁会导致性能瓶颈和系统负担增加,同时也可能导致死锁等问题。

1.2 乐观锁

乐观锁的思想是,每次访问共享资源时都假定其他线程不会同时访问该资源,因此不对该资源进行加锁保护,而是通过版本号、时间戳等机制来检测数据是否被其他线程修改过。如果检测到数据已经被修改过,则会进行回滚或重试等操作。在Java中,Atomic系列的原子类和StampedLock就是乐观锁的实现。乐观锁的优点是,避免了频繁地获取和释放锁,提高了并发性能,但是需要保证并发修改的场景较少,且对数据的一致性有一定的影响。

总的来说,悲观锁适用于并发写入比较多的场景,可以有效地保证数据的一致性,但是在高并发的情况下可能会影响性能。乐观锁适用于并发读取比较多的场景,可以提高并发性能,但是在并发修改较多的情况下可能会导致数据一致性问题。因此,在实际应用中需要根据具体的业务场景和性能要求来选择合适的锁策略。


2.重量级锁vs轻量级锁

Java中的轻量级锁和重量级锁是两种内置锁的实现方式,用于控制并发访问共享资源的情况。

2.1 轻量级锁

轻量级锁是一种基于对象头的锁实现方式,主要用于优化低竞争情况下的锁性能。当一个线程尝试获取对象的锁时,如果该对象没有被其他线程锁定,则该线程将对象头中的标志位改为“轻量级锁标志”,并将对象头中存储的线程ID更新为当前线程的ID。如果该对象已经被其他线程锁定,则该线程会自旋等待锁的释放。在自旋的过程中,如果其他线程已经释放了锁,则当前线程可以直接获取锁,否则就会膨胀为重量级锁。

2.2 重量级锁

重量级锁是一种基于操作系统互斥量的锁实现方式,主要用于高竞争情况下的锁性能。当一个线程尝试获取对象的锁时,如果该对象已经被其他线程锁定,则该线程会进入阻塞状态,直到其他线程释放了锁,该线程才能继续执行。重量级锁的实现涉及到操作系统内核的系统调用,因此在高并发情况下会产生较大的系统开销和资源消耗。

总的来说,轻量级锁适用于低竞争情况下的并发访问,可以有效地提高锁的性能,但在高并发情况下容易退化为重量级锁。重量级锁适用于高竞争情况下的并发访问,可以保证数据的正确性,但会带来较大的系统开销和资源消耗。因此,在实际应用中需要根据具体的业务场景和并发情况来选择合适的锁实现方式


3.自旋锁VS挂起等待锁

Java中的自旋锁和挂起等待锁是两种锁的实现方式,用于控制并发访问共享资源的情况。

3.1 自旋锁

自旋锁是一种基于忙等待的锁实现方式,当一个线程尝试获取锁时,如果该锁已经被其他线程占用,则该线程会不断地循环检测锁是否被释放,直到获取到锁为止。在Java中,synchronized关键字和ReentrantLock等内置锁都是自旋锁的实现。自旋锁的优点是可以避免线程的阻塞和切换,因此对于锁的竞争不是非常激烈的情况下,自旋锁可以提供较好的性能表现。但是,当锁的竞争非常激烈的时候,自旋锁的忙等待可能会浪费CPU时间,导致性能下降。

3.2 挂起等待锁

挂起等待锁是一种基于线程挂起的锁实现方式,当一个线程尝试获取锁时,如果该锁已经被其他线程占用,则该线程会被挂起等待锁的释放。在Java中,Object类中的wait()、notify()和notifyAll()方法就是基于挂起等待的锁实现方式。挂起等待锁的优点是可以避免线程的忙等待,节省CPU资源,同时也可以防止锁的竞争过于激烈,从而保证程序的稳定性。但是,挂起等待锁的缺点是在线程挂起和恢复的过程中,需要进行线程的切换和上下文切换,这些操作会带来一定的系统开销和性能下降。

总的来说,自旋锁适用于锁的竞争不是非常激烈的情况下,可以提高性能而挂起等待锁适用于锁的竞争比较激烈的情况下,可以保证程序的稳定性。在实际应用中,需要根据具体的业务场景和并发情况来选择合适的锁实现方式。


4.互斥锁VS读写锁

Java中的互斥锁和读写锁是两种常见的锁的实现方式,用于控制并发访问共享资源的情况。

4.1 互斥锁

互斥锁是一种最基本的锁实现方式,也是最常见的锁实现方式之一。当一个线程获取到互斥锁时,其他线程就无法再获取到该锁,直到该线程释放锁。在Java中,synchronized关键字和ReentrantLock等内置锁都是互斥锁的实现。互斥锁的优点是能够保证数据的一致性,但缺点是会带来较大的性能开销,因为每次获取锁时,需要进行线程的阻塞和上下文切换。

4.2 读写锁

读写锁是一种针对读写操作的锁实现方式,相对于互斥锁,读写锁可以实现更细粒度的并发控制。读写锁允许多个线程同时进行读操作,但只允许一个线程进行写操作。在Java中,ReentrantReadWriteLock是一种常用的读写锁实现方式。读写锁的优点是能够提高读操作的并发性能,从而提高程序的吞吐量,但缺点是可能会出现写饥饿问题,即写操作无法获取到锁,导致长时间等待,降低程序的响应性。

读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写 锁.

  • ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁操作

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

    这两个是操作系统内核提供的API在Java里进行封装的,系统API再底层的实现就是CPU指令级别了

其中,

  • 读加锁和读加锁之间, 不互斥.

  • 写加锁和写加锁之间, 互斥.

  • 读加锁和写加锁之间, 互斥.

注意, 只要是涉及到 “互斥”, 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多久了.

因此尽可能减少 “互斥” 的机会, 就是提高效率的重要途径.

读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是非常广泛存在的).

总结,在使用读写锁时,需要根据具体的业务场景和性能要求来进行权衡和选择。如果读操作远远多于写操作,并且数据的一致性要求不是非常高,那么可以考虑使用读写锁来提高程序的性能表现。如果读操作和写操作比较均衡,并且数据的一致性要求比较高,那么可能需要使用互斥锁来保证数据的正确性。


5.可重入锁VS不可重入锁

Java中的锁可以分为可重入锁和不可重入锁两种类型。

5.1 可重入锁

可重入锁是指同一个线程可以多次获取同一把锁而不会被阻塞,即线程可以重复地获取已经持有的锁,而不必担心死锁。可重入锁通常是通过一个计数器来记录锁的持有次数,每次加锁计数器加1,解锁计数器减1,当计数器为0时,锁被完全释放。Java中的synchronized关键字和ReentrantLock类都是可重入锁。

5.2 不可重入锁

不可重入锁是指同一个线程不能重复获取已经持有的锁,否则会被阻塞。不可重入锁通常需要在锁内部记录锁的持有线程,当线程再次尝试获取锁时,锁会判断是否是当前持有锁的线程,如果是,则允许获取锁,否则会被阻塞。Java中的普通的Lock接口的实现类,如不可重入锁ReentrantLock的父类Sync,以及Semaphore类都是不可重入锁。

需要注意的是,虽然可重入锁允许同一线程多次获取锁,但需要注意锁的释放顺序,避免出现死锁情况。而不可重入锁虽然不会出现死锁情况,但也需要注意线程的获取和释放锁的顺序,否则可能会出现资源竞争和死锁问题。

在实际应用中,需要根据具体的业务场景和性能要求来选择使用可重入锁还是不可重入锁,以确保程序的正确性和性能。


6.CAS

CAS(Compare and Swap,比较并交换)是一种基于原子操作的内存并发控制方式,是实现乐观锁的一种方式。CAS机制通常由硬件指令提供支持,但是在Java中,CAS机制是通过sun.misc.Unsafe类提供的一些本地方法实现的。

CAS机制包含三个参数:内存位置V,预期原值A,新值B。当且仅当V的值等于A时,CAS操作才会通过原子方式将V的值修改为B。如果V的值不等于A,那么CAS操作将不会执行任何操作,并且会返回V的当前值。

在Java中,CAS操作主要由java.util.concurrent.atomic包提供的原子类来实现。这些原子类提供了一系列基于CAS机制的线程安全的原子操作,包括原子加、原子减、原子更新等操作。这些原子类通过使用CAS机制,可以避免了锁机制的使用,从而提高了并发性能。

CAS机制的优点是:无锁化的实现方式,避免了锁机制的使用,可以避免由于锁竞争导致的线程挂起、唤醒等操作,从而提高了系统的并发性能。此外,CAS操作不会阻塞其他线程的访问,可以提高线程的响应速度。

但是,CAS机制也存在一些缺点。首先,CAS机制需要在循环中不断地进行CAS操作,直到成功为止,这可能会引起ABA问题。其次,CAS机制只能针对一个变量进行原子操作,如果需要对多个变量进行原子操作,就需要使用锁机制来保证操作的原子性。最后,CAS机制的实现依赖于CPU硬件支持,不同的CPU对于CAS操作的支持程度不同,可能会影响CAS机制的效率。

CAS(Compare and Swap,比较并交换)操作包含三个参数:内存位置V,预期原值A,新值B。具体的操作流程如下:

  1. 首先,CAS操作会先获取内存位置V的当前值,记为C。

  2. 接着,CAS操作会判断当前内存位置V的值是否等于预期原值A,如果相等,那么说明内存位置V的值没有被其他线程修改过,可以进行修改操作,否则说明内存位置V的值已经被其他线程修改过,不能进行修改操作,直接返回。

  3. 如果内存位置V的值等于预期原值A,那么CAS操作会使用新值B来替换内存位置V的当前值C。

  4. 在CAS操作过程中,由于CAS操作是原子的,因此只有一个线程能够成功执行CAS操作,其他线程会失败,但不会阻塞,而是重新开始执行CAS操作,直到成功为止。

需要注意的是,CAS操作涉及到CPU和内存之间的交互,而CPU和内存之间的交互需要通过寄存器来完成在执行CAS操作时,预期原值A通常会被加载到寄存器中,然后和内存位置V中的值进行比较,如果相等,则将新值B写入内存位置V中,否则重新从内存位置V中加载当前的值,并继续执行比较操作,直到比较成功为止。因此,可以说寄存器在CAS操作中扮演了一个重要的角色,但预期原值A并不是寄存器中的值

6.1 实现原子类:

在Java中,可以使用java.util.concurrent.atomic包提供的原子类来实现基于CAS机制的线程安全的原子操作。这些原子类包括AtomicInteger、AtomicLong、AtomicBoolean等。这些原子类提供了一系列基于CAS机制的线程安全的原子操作,包括原子加、原子减、原子更新等操作。这些原子类通过使用CAS机制,可以避免了锁机制的使用,从而提高了并发性能。

具体来说,原子类的实现方式通常是将要操作的变量作为一个volatile类型的成员变量,然后使用CAS机制来保证对该变量的原子操作。在执行原子操作时,先获取变量的当前值,然后通过CAS机制比较当前值和预期值是否相等,如果相等,则使用新值替换当前值,否则重新获取当前值并重复以上步骤,直到操作成功为止。

典型的就是 AtomicInteger 类.,下面代码演示AtomicInteger的使用:

public class AtomiclntegerDemo {
    private static int number = 0;
    private static AtomicInteger atomicInteger = new AtomicInteger(0);
 
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            for (int i = 0; i <=100000; i++) {
                //number++;
                atomicInteger.getAndIncrement();//++i 
                //AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
            }
        });
        thread.start();
        thread.join();
        System.out.println("最终结果" + atomicInteger.get());
    }
}
 

6.2 实现自旋锁:

自旋锁是一种基于忙等待的锁机制,它可以避免线程上下文切换所带来的性能损失,从而提高系统的并发性能。自旋锁的实现方式通常是使用一个标志位来表示锁的状态,当需要获取锁时,线程会不断地检查标志位的值,如果标志位为0,则将其设置为1,并执行临界区代码,否则线程将继续等待。

基于CAS机制实现自旋锁的方式是,将锁状态作为一个volatile类型的变量,并使用CAS机制来进行原子操作。当需要获取锁时,线程会先使用CAS机制将锁状态设置为1,如果设置成功,则表示获取锁成功,可以执行临界区代码;否则表示锁已被其他线程占用,线程将继续自旋等待。当线程释放锁时,只需要将锁状态设置为0即可。

需要注意的是,基于CAS机制实现的自旋锁可能会导致CPU资源的浪费,因为当线程不断地自旋等待时,会占用CPU资源,导致CPU利用率降低。因此,在实际应用中,应该根据实际情况来选择合适的锁机制,以充分利用CPU资源,提高系统的并发性能。


7.面试题,CAS的ABA问题怎么解决

ABA问题是指在使用CAS操作进行比较-交换时,如果变量在操作期间被修改了两次及以上,那么CAS操作可能会误判。为了解决ABA问题,可以引入版本号机制,即在变量值的基础上增加一个版本号,每次操作时都需要比较变量的值和版本号

具体来说,解决ABA问题的方式是,将要操作的变量和一个版本号一起打包成一个对象,称为版本号对象。在进行CAS操作时,不仅需要比较变量的值,还需要比较版本号。如果变量的值和版本号都与期望值相等,才会进行CAS操作,否则CAS操作失败。每次CAS操作成功后,都需要将版本号加一,从而防止下一次出现相同的值。

举个例子来说,假设有两个线程A和B同时对一个变量进行CAS操作。开始时,变量的值为1,版本号为1。线程A首先将变量的值从1修改为2,同时版本号加一变成2,然后将变量的值又修改为1,版本号再次加一变成3。此时,线程B也尝试对变量进行CAS操作,将变量的值从1修改为3,并将版本号设置为2。由于线程B对变量的修改版本号不同于线程A最后的版本号3,因此CAS操作失败,从而避免了ABA问题的发生。

需要注意的是,引入版本号虽然可以解决ABA问题,但也会带来额外的开销。每次操作都需要更新版本号,因此在高并发场景下可能会影响性能。因此,在实际应用中,需要根据具体情况来考虑是否引入版本号机制来解决ABA问题。

以下是一个使用AtomicStampedReference解决ABA问题的示例代码:

import java.util.concurrent.atomic.AtomicStampedReference;

public class CASWithVersionExample {
    private static final AtomicStampedReference count = new AtomicStampedReference<>(1, 1);

    public static void main(String[] args) {
        // 创建两个线程
        Thread thread1 = new Thread(() -> {
            int stamp = count.getStamp(); // 获取初始版本号
            int oldValue = count.getReference(); // 获取初始值
            System.out.println("Thread 1: old value is " + oldValue + ", old stamp is " + stamp);

            // 模拟线程执行过程中被其他线程干扰,使得 oldValue 被修改
            count.compareAndSet(oldValue, 2, stamp, stamp + 1); // 将变量的值由1改为2
            System.out.println("Thread 1: new value is " + count.getReference() + ", new stamp is " + count.getStamp());

            // 模拟操作完成之后,变量值又被改回1
            count.compareAndSet(2, 1, count.getStamp(), count.getStamp() + 1); // 将变量的值由2改回1
            System.out.println("Thread 1: latest value is " + count.getReference() + ", latest stamp is " + count.getStamp());
        });

        Thread thread2 = new Thread(() -> {
            int stamp = count.getStamp(); // 获取初始版本号
            int oldValue = count.getReference(); // 获取初始值
            System.out.println("Thread 2: old value is " + oldValue + ", old stamp is " + stamp);

            // 模拟操作过程中,变量的值被其他线程改为了2
            count.compareAndSet(oldValue, 3, stamp, stamp + 1); // 将变量的值由1改为3,此时线程A已经将变量的值由1改为2
            System.out.println("Thread 2: new value is " + count.getReference() + ", new stamp is " + count.getStamp());
        });

        thread1.start();
        thread2.start();
    }
}

在这个例子中,我们使用了AtomicStampedReference类来实现带版本号的CAS操作。首先,在main方法中创建了一个AtomicStampedReference对象,它包含了一个初始值1和一个初始版本号1。然后,创建了两个线程,模拟了ABA问题的发生过程:

  • 线程1首先获取变量的初始值1和初始版本号1,然后将变量的值修改为2,版本号加一,接着将变量的值又改回1,版本号再加一。
  • 线程2获取变量的初始值1和初始版本号1,然后将变量的值修改为3,此时线程1已经将变量的值由1改为2,版本号也从1变成了2,因此线程2的CAS操作会失败。

从输出结果中可以看到,线程1的CAS操作成功了两次,而线程2的CAS操作失败了一次。这就是因为我们使用了带版本号的CAS操作,它可以有效地防止ABA问题的发生。


你可能感兴趣的:(面试,职场和发展,java)