【并发专题】并发编程核心问题以及锁的底层实现

目录

一、多线程

二、并发执行与并行执行

三、并发编程的核心问题

3.1不可见性

3.2 乱序性

3.3 非原子性

3.4 并发问题总结

四、volatile关键字

五、如何保证原子性

5.1 锁实现

5.2 原子类

六、CAS

6.1 什么是CAS?

6.1 CAS三大问题

七、锁分类

7.1 乐观锁与悲观锁

7.2 可重入锁

7.3 读写锁

7.4 分段锁

7.5 自旋锁

7.6 共享锁和独享锁

7.7 公平锁和非公平锁

八、锁状态

九、synchronized锁实现

9.1 修饰同步代码块

9.2 修饰同步方法 

十、Reentrantlock锁实现

10.1 实现原理

* 关于AQS

10.2 ReentrantLock怎么实现公平锁的?

10.3 非公平锁和公平锁的区别


一、多线程

优点:提高程序的响应处理速度,提高cpu的利用率,压榨硬件的剩余价值.

存在的问题:多线程访问同一资源.

产生的原因:现在的cpu是多内核的,在理论上是可以同时执行多个线程的.

二、并发执行与并行执行

并行执行:在同一个时间节点上,多个线程同时执行.

例如,一个计算机同时执行多个程序、多个线程或者多个进程时,就是采用并行的方式来处理任务,这样能够提高计算机的处理效率

并发执行:在一个时间段内,多个线程交替执行(即一个任务执行一段时间后,再执行另外一个任务)

所以并发执行宏观上感觉是同时执行的,微观上其实是一个一个执行的。它是通过操作系统的协作调度来实现各个任务的切换,达到看上去同时进行的效果。

例如,一个多线程程序中的多个线程就是同时运行的,但是因为 CPU 只能处理一个线程,所以在任意时刻只有一个线程在执行,线程之间会通过竞争的方式来获取 CPU 的时间片。

三、并发编程的核心问题

并发编程的核心问题就是多个线程访问共享数据时,出现问题的根本原因

3.1不可见性

并发编程的不可见性与Java的内存模型有关,也就是JMM( Java Memory Model )

 •  Java内存模型是变量数据都存在主内存中,每个线程还有自己的工作内存。

 •  规定线程不能直接对主内存中的数据操作,只能把主内存数据加载到自己的工作内存中操作,

操作完成后,再写回主内存。

 •  这样的设计就会引发不可见性问题,即一个线程A在自己的工作内存中操作了数据后,此时另一个线程B也正在操作,但是线程B不知道数据已经被另一个线程A修改了

3.2 乱序性

 •  为了优化指令执行,在执行一些等待时间长的执行时,可以把其他的一些指令提前执行,提高速度。

 •  但是在多线程场景下,由于cpu时间片轮换,不同的线程可能会交替执行不同的代码,就可能会出现问题。

3.3 非原子性

线程的切换执行带来非原子性问题,cpu保证的原子性执行是cpu指令级别的,但是对于高级语言的一条代码,有时是要拆分成多条指令来执行。

线程A在执行到某条指令时,操作系统会切换到其他线程去执行,这样这条高级语言指令执行就是非原子性的。

例如:++操作,可以分成3条指令

  • 加载主内存数据到工作内存
  • 在工作内存操作数据
  • 写回主内存

3.4 并发问题总结

在多线程环境中,JMM (也可以理解为缓存) 导致的不可见性问题,编译优化带来了乱序性问题,线程切换带来了非原子性问题

其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序安全性和性能,但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么, 以及如何规避。

在了解并发编程存在的问题后,接下来该如何解决这些问题呢?

四、volatile关键字

 •  volatile 修饰的变量,在一个线程中被修改后,对其它线程立即可见。底层会将工作内存中的数据同步到其他线程的工作内存,使其立即可见。

 •  volatile 关键字修饰后的变量,在执行时禁止指令重排来保证顺序性,也解决了乱序性

但是volatile无法解决非原子性问题

总的来说,volatile可以确保多个线程对共享变量的操作一致,避免了数据不一致的问题。但是它不能保证原子性,因此对于需要保证原子性的操作,还需要使用其他同步机制,如synchronized关键字或java.util.concurrent.atomic包中的原子类。 

▼ 代码测试

五、如何保证原子性

 •  解决非原子性问题,可以通过加锁的方式实现,synchronized和ReentrantLock都可以实现。

 •  Java中还提供了一种方案,在不加锁的情况下,实现++操作的原子性。就是原子类,在java.util.concurrent 包下,定义了许多与并发编程相关的处理类,简称JUC.

5.1 锁实现

synchronized用来保证代码的原子性,主要由三种用法:

 •  修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。

 不同的对象实例之间并不会互相影响,它们的锁是相互独立的。因此,如果不同的线程在不同的对象实例上执行同一个同步方法,它们之间并不会因为共享变量而产生互斥的效果。

 •  修饰静态方法:给当前类加锁,在同一时间内,只能有一个线程持有该类对应的 Class 对象的锁,其他线程需要等待锁的释放才能继续执行该静态方法。

应该尽量避免持有锁的时间过长,否则可能会导致其他线程长时间等待,从而影响系统性能。同时,也要注意避免死锁的情况。

 •  修饰代码块:指定一个同步锁对象,这个对象可以是具体的Object或者是类.class。在同一时间内,只能有一个线程持有该同步锁对象的锁,其他线程需要等待锁的释放才能继续执行该代码块。

同步锁并不是对整个代码块进行加锁,而是对同步锁对象进行加锁。因此,如果在不同的代码块中使用了相同的同步锁对象,它们之间也会产生互斥的效果。而如果使用不同的同步锁对象,则它们之间并不会产生互斥的效果。 

5.2 原子类

在原子类中,最常被问到AtomicInteger的原理是什么?

一句话概括:使用CAS实现。

在AtomicInteger中,CAS操作的流程如下:

1. 调用 incrementAndGet()方法,该方法会通过调用unsafe.getAndAddInt()方法,获取当前 AtomicInteger对象的值val

2. 将 val + 1 得到新的值 next

3. 使用 unsafe.compareAndSwapInt() 方法进行 CAS 操作,将对象中当前值与预期值(步骤1中获取的val)进行比较,如果相等,则将 next赋值给val;否则返回 false

4. 如果CAS操作返回false,说明有其他线程已经修改了AtomicInteger对象的值,需要重新执行步骤 1 

▼ 测试代码

import java.util.concurrent.atomic.AtomicInteger;

public class Atomic {

   private  static AtomicInteger atomicInteger = new AtomicInteger(0);

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.println(atomicInteger.incrementAndGet());
                }
            }.start();
        }

    }
}

在 CAS 操作中,由于只有一个线程可以成功修改共享变量的值,因此可以保证操作的原子性,即多线程同时修改AtomicInteger变量时也不会出现竞态条件。这样就可以在多线程环境下安全地对AtomicInteger进行整型变量操作。其它的原子操作类基本都是大同小异。

六、CAS

6.1 什么是CAS?

CAS叫做CompareAndSwap,比较并交换,主要是通过处理器的指令来保证操作的原子性的。

CAS 操作包含三个参数:共享变量的内存地址(V)、预期原值(A)和新值(B),当且仅当内存地址 V 的值等于 A 时,才将 V 的值修改为 B;否则,不会执行任何操作。

在多线程场景下,使用 CAS 操作可以确保多个线程同时修改某个变量时,只有一个线程能够成功修改。其他线程需要重试或者等待。这样就避免了传统锁机制中的锁竞争和死锁等问题,提高了系统的并发性能。

6.1 CAS三大问题

ABA问题

ABA 问题是指一个变量从A变成B,再从B变成A,这样的操作序列可能会被CAS操作误判为未被其他线程修改过。

例如线程A读取了某个变量的值为 A,然后被挂起,线程B修改了这个变量的值为B,然后又修改回了A,此时线程A恢复执行,进行CAS操作,此时仍然可以成功,因为此时变量的值还是A。

怎么解决ABA问题?

加版本号

每次修改变量,都在这个变量的版本号上加1,这样,刚刚A->B->A,虽然A的值没变,但是它的版本号已经变了,再判断版本号就会发现此时的A已经被改过了。

比如使用JDK5中的AtomicStampedReference类或JDK8中的LongAdder类。这些原子类型不仅包含数据本身,还包含一个版本号,每次进行操作时都会更新版本号,只有当版本号和期望值都相等时才能执行更新,这样可以避免 ABA 问题的影响。

循环性能开销

自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。

怎么解决循环性能开销问题?

可以使用自适应自旋锁,即在多次操作失败后逐渐加长自旋时间或者放弃自旋锁转为阻塞锁;

只能保证一个变量的原子操作

CAS 保证的是对一个变量执行操作的原子性,如果需要对多个变量进行复合操作,CAS 操作就无法保证整个操作的原子性。

怎么解决只能保证一个变量的原子操作问题?

可以使用锁机制来保证整个复合操作的原子性。

例如,使用 synchronized 关键字或 ReentrantLock 类来保证多个变量的复合操作的原子性。在锁的作用下,只有一个线程可以执行复合操作,其他线程需要等待该线程释放锁后才能继续执行。这样就可以保证整个操作的原子性了。
 

七、锁分类

锁分类,不全是指Java中的锁,有的指所得特征,有的指锁的实现,有的指锁的状态。

7.1 乐观锁与悲观锁

乐观锁:是一种不加锁的实现,例如原子类,认为不加锁,采用自旋方式尝试修改共享数据,是不会有问题的(无锁实现)

悲观锁:是一种加锁实现,例如synchronized和ReentrantLock,认为不加锁修改共享数据会出现问题。

7.2 可重入锁

可重入锁又称递归锁,当同一个线程,获取锁进入到外层方法后,可以在内层进入到另一个方法(内层方法与外层方法使用的是同一把锁)

synchronized和ReentrantLock都是可重入锁

7.3 读写锁

ReentrantReadWriteLock 读写锁实现

读读不互斥、读写互斥、写写互斥;只要有一个线程写操作,其他线程就不能写,保证读不到脏数据,且最大的保证读的效率。

7.4 分段锁

将锁的粒度进一步细化,提高并发效率

Hashtable是线程安全的,方法上都加了锁,假如有两个线程同时读,也只能一个一个 读,并发效率低。

ConcurrentHashMap没有给方法上加锁,使用hash表中的每一个位置上的第一个对象作为锁对象,这样就可以多个线程对不同位置进行操作,相互不影响,只有对同一个位置进行操作时才互斥。

有多把锁,提高并发操作的效率。

7.5 自旋锁

线程尝试不断的获取锁,当第一次获取不到时,线程不阻塞,尝试继续获取锁,有可能后面几次尝试后,有其他线程释放了锁,此时就可以获取锁,当尝试获取到一定次数后(默认10次),任然获取不到锁,那么可以进入阻塞状态.

synchronized 就是一种自旋锁.

并发量的低的情况下适合自旋.

7.6 共享锁和独享锁

共享锁:锁可以被多个线程共享,读写锁中读锁就是共享锁。

独占锁:一把锁只能有一个线程使用,如读写锁的写锁,synchronized 和 ReentrantLock都是独占锁。

7.7 公平锁和非公平锁

公平锁:可以按照请求获得锁的顺序来得到锁.

非公平锁:不按照请求获得锁的顺序来得到锁.

synchronized是非公平的;ReentrantLock默认是非公平的,但可以通过构造方法参数设置选择公平实现或非公平实现。

八、锁状态

Java中为了synchronized 进行优化,提供了3种锁状态:

偏向锁:一段同步代码块一直由一个线程执行,那么会在锁对象中记录下了线程信息,可以直接获得锁。

轻量级锁:当锁状态为偏向锁时,此时又有其他线程访问,锁状态升级为轻量级锁,线程不阻塞,采用自旋方式获取锁。

重量级锁:当锁状态为轻量级锁时,如果有大量的线程到来,大量的线程自旋,锁状态升级为重量级锁,自旋的线程会进入到阻塞状态,由操作系统去调度管理。

九、synchronized锁实现

9.1 修饰同步代码块

我们使用synchronized的时候,发现不用自己去lock和unlock,是因为JVM帮我们把这个事情做了。

synchronized修饰同步代码块,JVM采用monitorenter、monitorexit两个指令来实现同步,在进入到同步代码块时,会执行monitorenter指令,离开同步代码块时或者出现异常时,执行monitorexit指令。

▼ 源代码 

public class SyncDemo {
    public void main(String[] args) {
        synchronized (this) {
            int a = 1;
        }
    }
}

▼ 字节码

public void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=5, args_size=2
         0: aload_0
         1: dup
         2: astore_2
         3: monitorenter
         4: iconst_1
         5: istore_3
         6: aload_2
         7: monitorexit
         8: goto          18
        11: astore        4
        13: aload_2
        14: monitorexit
        15: aload         4
        17: athrow
        18: return

9.2 修饰同步方法 

synchronized修饰同步方法时,在指令中会为方法添加ACC_SYNCHRONIZED标志

源代码

public class SyncDemo {
    public synchronized void main(String[] args) {
        int a = 1;
    }
}

字节码

synchronized锁住的是什么呢?

实例对象结构里有对象头,对象头里面有一块结构叫Mark Word,Mark Word指针指向了monitor。

所谓的Monitor其实是一种同步机制,我们可以称为内部锁或者Monitor锁。

monitorenter、monitorexit或者ACC_SYNCHRONIZED都是基于Monitor实现的。

反编译class文件方法:

反编译一段synchronized修饰代码块代码,javap -c -s -v -l ***.class,可以看到相应的字节码指令。 

十、Reentrantlock锁实现

10.1 实现原理

ReentrantLock是一种可重入的排它锁,主要用来解决多线程对共享资源竞争的问题;它提供了比synchronized关键字更加灵活的锁机制,其实现原理主要涉及以下三个方面:

基本结构

ReentrantLock内部维护了一个Sync对象(AbstractQueuedSynchronizer类的子类),Sync持有锁、等待队列等状态信息,实际上 ReentrantLock的大部分功能都是由Sync来实现的。

* 关于AQS

AQS(抽象同步队列)是一个实现线程同步的框架,并发包中很多类的底层都用到了AQS,在AQS中维护了一个状态state,表示有没有线程访问共享数据,默认0表示没有线程访问。当需要修改状态时会调用compareAndSetState方法。

加锁过程

当一个线程调用ReentrantLock的lock()方法时,会先尝试CAS操作获取锁,如果成功则返回;否则,线程会被放入等待队列中,等待唤醒重新尝试获取锁。

如果一个线程已经持有了锁,那么它可以重入这个锁,即继续获取该锁而不会被阻塞。

ReentrantLock通过维护一个计数器来实现重入锁功能,每次重入计数器加1,每次释放锁计数器减1,当计数器为0时,锁被释放。

解锁过程

当一个线程调用ReentrantLock的unlock()方法时,会将计数器减1,如果计数器变为了0,则锁被完全释放。如果计数器还大于0,则表示有其他线程正在等待该锁,此时会唤醒等待队列中的一个线程来获取锁。

总结:

ReentrantLock的实现原理主要是基于CAS操作和等待队列来实现。它通过Sync对象来维护锁的状态,支持重入锁和公平锁等特性,提供了比synchronized更加灵活的锁机制,是Java并发编程中常用的同步工具之一。

10.2 ReentrantLock怎么实现公平锁的?

ReentrantLock可以通过构造函数的参数来控制锁的公平性,如果传入 true,就表示该锁是公平的;如果传入 false,就表示该锁是不公平的。

new ReentrantLock()构造函数默认创建的是非公平锁 NonfairSync

同时也可以在创建锁构造函数中传入具体参数创建公平锁 FairSync 

10.3 非公平锁和公平锁的区别

● 非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了。

● 非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面。