JUC多线程基础(二)

1. Volatile的使用

1.1 volatile简介

jdk1.6之后虽然对synchronized做了大量的优化,但它还是一个重量级锁。而volatile是轻量级的,比synchronized要的成本低。

一个变量如果用volatile修饰了,则Java可以确保所有线程看到这个变量的值是一致的,如果某个线程对volatile修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,解决了内存可见性。

1.2 volatile如何实现内存可见性

线程写的过程:

  1. 改变线程本地内存中volatile变量副本的值
  2. 将改变后的副本的值从本地内存刷新到主内存

线程读的过程:

  1. 从主内存中读取volatil变量的最新值到线程的本地内存中
  2. 从本地内存中读取volatile变量的副本

原理:
写操作时,通过在写操作指令后加入一条store屏障指令,让本地内存中变量的值能够刷新到主内存中
读操作时,通过在读操作前加入一条load屏障指令,及时读取到变量在主内存的值
内存屏障(Memory Barrier)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序

1.3 volatile存在的问题

volatile无法解决原子性问题,只能解决可见性有序性

例子:

@Test
public void test() throws InterruptedException {
    
    
    MyRunnable myRunnable = new MyRunnable();
    for (int i = 0; i < 5; i ++ ) {
    
    
        Thread thread = new Thread(myRunnable);
        thread.start();
    }

    Thread.sleep(1000);
    System.out.println(myRunnable.count);
}

class MyRunnable implements Runnable {
    
    
    public volatile int count = 0;
    @Override
    public void run() {
    
    
        for (int i = 0; i < 5000; i ++ ) {
    
    
            count ++ ;
        }
    }
}

在这里插入图片描述
如上,正常count值应该为25000,但是因为原子性问题只到了18418


解决方法:

  1. 使用synchronized保证原子性
  2. 使用可重入锁ReentrantLock
  3. 使用Atomic原子操作
@Test
public void test() throws InterruptedException {
    
    
    MyRunnable myRunnable = new MyRunnable();
    for (int i = 0; i < 5; i ++ ) {
    
    
        Thread thread = new Thread(myRunnable);
        thread.start();
    }

    Thread.sleep(1000);
    System.out.println(myRunnable.count);
}

class MyRunnable implements Runnable {
    
    
    public volatile AtomicInteger count = new AtomicInteger();
    @Override
    public void run() {
    
    
        for (int i = 0; i < 5000; i ++ ) {
    
    
            count.incrementAndGet();
        }
    }
}

在这里插入图片描述
如图,解决

1.4 使用场景

  1. 不依赖当前值
  2. 独立于其他变量

1.5 volatile和synchronized的比较

  1. volatile不需要加锁,比synchronized更轻便,不会阻塞线程
  2. synchronized既能保证可见性,又能保证原子性,而volatile只能保证可见性,无法保证原子性

2. CAS

2.1 CAS简介

CAS,Compare And Swap,即比较并交换。同步组件中大量使用CAS技术实现了Java多线程的并发操作。整个AQS同步组件、Atomic原子类操作等等都是以CAS实现的,甚至ConcurrentHashMap在1.8的版本中也调整为了CAS+Synchronized。可以说CAS是整个JUC的基石。
在这里插入图片描述

2.2 CAS原理

CAS的思想很简单:三个参数,一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当旧的预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,通过自旋的方式等待并再次尝试,直到成功。

CAS在 先比较后修改 这个CAS过程中,根本没有获取锁,释放锁的操作,是硬件层面的原子操作,跟JMM内存模型没有关系。大家可以理解为直接使用其他的语言,在JVM虚拟机之外直接操作计算机硬件,正因为如此,对比synchronized的同步,少了很多的逻辑步骤,使得性能大为提高。

JUC下的atomic类都是通过CAS来实现的,下面就是一个AtomicInteger原子操作类的例子,在其中使用了Unsafe unsafe = Unsafe.getUnsafe()。Unsafe 是CAS的核心类,它提供了硬件级别的原子操作。

2.3 多CPU的CAS处理

CPU提供了两种方法来实现多处理器的原子操作:总线加锁或者缓存加锁:

  1. 总线加锁:总线加锁就是就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。但是这种处理方式显得有点儿霸道,不厚道,他把CPU和内存之间的通信锁住了,在锁定期间,其他处理器都不能其他内存地址的数据,其开销有点儿大。
  2. 缓存加锁:其实针对于上面那种情况我们只需要保证在同一时刻对某个内存地址的操作是原子性的即可。缓存加锁就是缓存在内存区域的数据如果在加锁期间,当它执行锁操作写回内存时,处理器不在输出LOCK#信号,而是修改内部的内存地址,利用缓存一致性协议来保证原子性。缓存一致性机制可以保证同一个内存区域的数据仅能被一个处理器修改,也就是说当CPU1修改缓存行中的 i 时使用缓存锁定,那么CPU2就不能同时缓存了 i 的缓存行。

2.4 CAS缺陷

  1. 循环时间过长
  2. 只能保证一个共享变量原子操作:如果是多个共享变量就只能使用锁了
  3. ABA问题

什么是ABA问题?

CAS需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种
情况:如果一个值原来是A,变成了B,然后又变成了A,那么在CAS检查的时候会发
现没有改变,但是实质上它已经发生了改变,这就是所谓的ABA问题。对于ABA问题
其解决方案是加上版本号,即在每个变量都加上一个版本号,每次改变时加1,即
A —> B —> A,变成1A —> 2B —> 3A。

解决办法:

CAS的ABA隐患问题,Java提供了AtomicStampedReference来解决。
AtomicStampedReference通过包装[E,Integer]的元组来对对象标记版本戳stamp,
从而避免ABA问题。对于上面的案例应该线程1会失败。

2.5 atomic包

主要包括:

  1. 基本类型
  2. 引用类型
  3. 数组类型
  4. 对象的属性修改器类型
  5. JDK8新增类
// 1. 基本类型
// 整形原子类:AtomicInteger
// 长整型原子类:AtomicLong
// 布尔原子类:AtomicBoolean


// 2. 引用类型
// 引用类型原子类:AtomicReference
// 可以鉴别版本号的引用类型原子类:AtomicStampedReference
// 原子更新带有标记位的引用类型:AtomicMarkableReference


// 3. 数组类型
// 整形数组原子类:AtomicIntegerArray
// 长整形数组原子类:AtomicLongArray
// 引用类型数组原子类:AtomicReferenceArray


// 4. 对象的属性修改类型
// 原子更新整形字段的更新器:AtomicIntegerFieldUpdater
// 原子更新长整形字段的更新器:AtomicLongFieldUpdater
// 原子更新引用类形字段的更新器:AtomicReferenceFieldUpdater



// 5. JDK1.8新增类
// 双浮点型原子类:DoubleAdder
// 长整型原子类:LongAdder
// DoubleAccumulator:类似DoubleAdder,但要更加灵活(要传入一个函数式接口) // LongAccumulator:类似LongAdder,但要更加灵活(要传入一个函数式接口)

3. AQS

3.1 AQS简介

AQS(AbstractQueuedSynchronizer),即队列同步器。它是构建锁或者其他同步组件的基础框架(如ReentrantLock、ReentrantReadWriteLock、Semaphore等),是多数JUC组件的基础

3.2 AQS的作用

Java的内置锁一直都是备受争议的,在JDK 1.6之前,synchronized这个重量级锁其性能一直都是较为低下,虽然在1.6后,进行大量的锁优化策略,但是与Lock相比synchronized还是存在一些缺陷的:它缺少了获取锁与释放锁的可操作性,可中断、超时获取锁,而且独占式在高并发场景下性能大打折扣。

AQS解决了实现同步器时涉及到的大量细节问题,例如获取同步状态、FIFO同步队列。基于AQS来构建同步器可以带来很多好处。它不仅能够极大地减少实现工作,而且也不必处理在多个位置上发生的竞争问题。

3.3 state状态

AQS维护了一个volatile int类型的变量state表示当前同步状态。当state>0时表示已经获取了锁,当state = 0时表示释放了锁。

提供了三种方法:

  1. getState:返回同步状态的当前值
  2. setState:设置当前同步状态
  3. compareAndSetState:使用CAS设置当前状态
    均为原子性操作

3.4 资源共享方式

  1. Exclusive:独占,例如ReentrantLock
  2. Share:共享,例如CountDwonLatch

3.5 CLH同步队列

AQS内部维护着一个FIFO队列,该队列就是CLH同步队列,遵循FIFO原则( First Input First Output先进先出)。CLH同步队列是一个FIFO双向队列,AQS依赖它来完成同步状态的管理。


当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态

4. 锁

4.1 互斥锁

在编程中,引入了对象互斥锁的概念,来保证共享数据操作的完整性。每个对象都对应于一个可称为" 互斥锁" 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。

4.2 阻塞锁

阻塞锁,可以说是让线程进入阻塞状态进行等待,当获得相应的信号(唤醒,时间) 时,才可以进入线程的准备就绪状态,准备就绪状态的所有线程,通过竞争,进入运行状态。

4.3 读写锁

读写锁实际是一种特殊的自旋锁,它把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。

读写锁相对于自旋锁而言,能提高并发性,因为在多处理器系统中,它允许同时有多个读者来访问共享资源,最大可能的读者数为实际的逻辑CPU数。写者是排他性的,一个读写锁同时只能有一个写者或多个读者(与CPU数相关),但不能同时既有读者又有写者。

4.4 公平锁

  1. 公平锁(Fair):加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得
  2. 非公平锁(Nonfair):加锁时不考虑排队等待问题,直接尝试获取锁,获取不到自动到队尾等待

非公平锁性能比公平锁高,因为公平锁需要在多核的情况下维护一个队列

4.5 ReentrantLock

ReentrantLock,可重入锁,是一种递归无阻塞的同步机制。它可以等同于synchronized的使用,但是ReentrantLock提供了比synchronized更强大、灵活的锁机制,可以减少死锁发生的概率。

ReentrantLock还提供了公平锁和非公平锁的选择,构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

底层使用了同步队列

5. Condition

5.1 Condition简介

在没有Lock之前,我们使用synchronized来控制同步,配合Object的wait()、notify()系列方法可以实现等待/通知模式。在JDK5后,Java提供了Lock接口,相对于Synchronized而言,Lock提供了条件Condition,对线程的等待、唤醒操作更加详细和灵活。
在这里插入图片描述

5.2 使用

Condition提供了一系列的方法来对阻塞和唤醒线程:

  1. await() :造成当前线程在接到信号或被中断之前一直处于等待状态
  2. await(long time, TimeUnit unit) :造成当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。
  3. awaitNanos(long nanosTimeout) :造成当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。返回值表示剩余时间,如果在nanosTimesout之前唤醒,那么返回值 = nanosTimeout – 消耗时间,如果返回值 <= 0 ,则可以认定它已经超时了。
  4. awaitUninterruptibly():造成当前线程在接到信号之前一直处于等待状态。
  5. awaitUntil(Date deadline) :造成当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态。如果没有到指定时间就被通知,则返回true,否则表示到了指定时间,返回返回false。
  6. signal():唤醒一个等待线程。该线程从等待方法返回前必须获得与Condition相关的锁。
  7. signalAll():唤醒所有等待线程。能够从等待方法返回的线程必须获得与Condition相关的锁。

Condition是一种广义上的条件队列(等待队列)。他为线程提供了一种更为灵活的等待/通知模式,线程在调用await方法后执行挂起操作,直到线程等待的某个条件为真时才会被唤醒。Condition必须要配合锁一起使用,因为对共享状态变量的访问发生在多线程环境下。一个Condition的实例必须与一个Lock绑定,因此Condition一般都是作为Lock的内部实现。

private Lock reentrantLock = new ReentrantLock();
private Condition condition = reentrantLock.newCondition();
@Test
public void test() {
    
    
	Thread thread = new Thread(new Runnable() {
    
    
          public void run() {
    
    
               reentrantLock.lock();
               System.out.println("等待唤醒");
               condition.await();
               reentrantLock.unlock();
           }
    }).start();
    Thread.sleep(1000);
    condition.signalAll();
}

5.3 Condition的实现

获取一个Condition必须通过Lock的newCondition()方法。该方法定义在接口Lock下面,返回的结果是绑定到此 Lock 实例的新 Condition 实例。Condition为一个接口,其下仅有一个实现类ConditionObject,由于Condition的操作需要获取相关的锁,而AQS则是同步锁的实现基础,所以ConditionObject则定义为AQS的内部类。定义如下:

public class ConditionObject implements Condition, java.io.Serializable {
    
    
}

等待队列:

每个Condition对象都包含着一个FIFO队列,该队列是Condition对象通知/等待功能的关键。在队列中每一个节点都包含着一个线程引用,该线程就是在该Condition对象上等待的线程。

猜你喜欢

转载自blog.csdn.net/weixin_43795939/article/details/112786770
今日推荐