并发编程学习笔记
📖

并发编程学习笔记

Created
May 18, 2021 08:28 AM
status
Published

进程与线程

线程是进程中的一个实体,线程本身是不会独立存在的。
进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,线程则是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源。

并行与并发

CPU将时间片分给不同的CPU所使用,由于 时间片时间非常短 ,所以给人的感觉是多个线程 同时运行
  • 并发是同一时间应对(dealing with)多件事情的能力
  • 并行是同一时间动手做(doing)多件事情的能力
现在是CPU多核时代了,所以实际上线程是 并行并发同时存在的

同步与异步

以调用方角度来讲,如果
  • 需要等待结果返回 ,才能继续运行就是同步
  • 不需要等待结果返回 ,就能继续运行就是异步
多线程可以让方法执行变为异步的(即不要巴巴干等着)。
多核cpu可以并行跑多个线程。

JAVA线程

创建和运行线程

  1. 继承 Thread ,重写 run 方法。
  1. 实现 Runnable 接口,重写 run 方法
  1. 使用 FutureTask 方式( 间接实现了 Runnable 接口
public class Test {
    public static class CallerTask implements Callable<String> {
        @Override
        public String call() throws Exception {
            //do some work here
            return null;
        }
    }
    public static void main(String[] args) {
        FutureTask<String> futureTask=new FutureTask<>(new CallerTask());
        new Thread(futureTask).start();
        try {
            String result=futureTask.get();
            System.out.println(result);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
小结
  • 继承的方式可以更好的传递参数,可以在子类中添加成员变量,继承了Thread后不能再继承其他的类。
  • 实现Runnable接口的方式只能使用主线程里面被声明为final的变量,但是可以做到任务和代码的分离。
  • 前两者都不能拿到任务的返回值,但是FutureTask可以。

查看进程的方法

1️⃣
windows
  • tasklist可以查看线程
  • taskkill可以杀死线程
2️⃣
java
  • jps 命令查看所有 Java 进程
  • jstack <PID> 查看某个 Java 进程(PID)的所有线程状态
3️⃣
linux
  • ps -fe 查看所有进程
  • ps -fT -p <PID> 查看某个进程(PID)的所有线程
  • kill 杀死进程 top 按大写 H 切换是否显示线程
  • top -H -p <PID> 查看某个进程(PID)的所有线程

线程运行的原理

每个线程启动后,虚拟机就会为其分配一块栈内存。
  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

理解上下文切换

每个CPU同一时刻只能被一个线程使用
CPU资源的分配采用了时间片轮转的策略,也就是给每个线程分配一个时间片。
当前线程使用完时间片后,就会处于就绪状态并让出CPU让其他线程占用,这就是上下文切换。
线程上下文切换时机有:
  • 当前线程的CPU时间片使用完处于就绪状态时
  • 当前线程被其他线程中断时。
  • 垃圾回收

方法解析

start() vs run()

只有调用 start() 方法才是开启了线程,直接调用 run() 方法和调用普通方法无异。

sleep

🆚

yield

sleep(long millis)返回值为void的方法,由Thread直接提供。
yield()方法是无参且返回值为void的方法,由Thread类直接提供。
当一个执行中的线程调用了Threadsleep方 法后,调用线程会暂时让出指定时间的执行权,也就是在这期间不参与CPU的调度,但是该线程所拥有的监视器资源,比如锁还是持有不让出的。指定的睡眠时间到了后该函数会正常返回,线程就处于就绪状态,然后参与CPU的调度,获取到CPU资源后就可以继续运行了。
当一个线程调用了Thread类的静态方法yield()时,是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,这暗示线程调度器现在就可以进行下一轮的线程调度。( 就是让出自己还没有用完的时间片 )。 在让出CPU使用权后,会进入就绪状态
sleepyield的区别在于:
  • 调用sleep方法时调用线程会被阻塞挂起指定的时间,在这期间线程调度器不会去调度该线程。
  • 调用yield方法时,线程只是让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度时就有可能调度到自己。

join

join()方法是无参且返回值为void的方法,由Thread类直接提供。
join()的作用是让主线程 等待子线程 结束之后才能继续运行。
当线程A调用了B的join()方法后,线程A会进入阻塞状态,等待线程B执行完毕。
可以理解为这两个线程完成了同步
join(long) 是有时效的等待,如果子线程在时限里还没完成,直接结束,如果提前完成,等待也提前结束。

wait()

当一个线程调用一个共享变量的wait()方法时,该调用线程会被阻塞挂起。直到:
  1. 其他线程调用了该共享对象的notify()或者notifyall()方法。
  1. 其他线程调用了该线程的interrupt()方法。
一个线程可以从挂起状态变为可以运行状态,即使该线程没有被其他线程调用notify()notifyAll()方法进行通知,或者被中断,或者等待超时,这就是所谓的虚假唤醒。虽然很少出现,但是要防范于未然。
如下是防范虚假唤醒的例子:
synchronized (obj){
    while (条件不满足) {
        obj.wait();
    }
}
当前线程调用共享变量的wait()方法后只会释放当前共享变量上的锁,如果当前线程还持有其他共享变量的锁,则这些锁是不会被释放的。

wait(long timeout)

相比较于wait()方法函数的区别是:在指定的timeout(单位:ms)内要是没有被唤醒,那么还是会因为超时而返回。
timeout设置为0的效果和wait()一样,因为wait()里就是调用了wait(0)
如果传递的timeout<0,那么会报错。

notify()

notifyAll()

调用了一个共享对象的notify()函数后,会随机唤醒一个在该共享变量上调用wait系列方法后被挂起的线程(即是进入了就绪队列)。
notify()不同在于会唤醒全部的线程。

线程中断

Java中的线程中断是一种线程间的协作模式。通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理
  • void interrupt()中断线程。当线程A运行时,线程B调用了A的interrupt()方法,那么线程A的中断标志会被设置为true,此时A并没有被中断,会继续往下执行。
  • boolean isInterrupted()检测 线程 是否被中断,如果是返回true,否则返回false。
  • boolean interrupted()检测当前线程是否被中断,如果是返回true,否则返回false。与boolean isInterrupted()不同的是,该方法如果发现当前线程被中断,则会清除中断标志。
interrupted()判断的是当前线程的状态,即
Thread.interrupted();
anotherThread.interrupted();
interrupted()
上方这四行代码返回的都是当前线程(主线程)的运行状态(而不是anotherThread的)。
联想到 interrupted() 是一个 static 方法,这些就好理解了。
interrupt 还能打断park状态下的线程, 如果中断标志为true, park 方法会失效

守护线程和用户线程

Java中的线程可以分为两类:
  • 1️⃣daemon线程(守护线程)
  • 2️⃣user线程(用户线程)。
二者的区别之一是当最后一个非守护线程结束时,JVM会正常退出,而不管当前是否有守护线程 就是说守护线程是否结束并不影响JVM的退出
可以通过线程的setDaemon()方法传入true来将线程设置为守护线程。
Thread daemonThread=new Thread(new Runnable() {
    @Override
    public void run() {
    }
});
daemonThread.setDaemon(true);
如果你希望在主线程结束后JVM进程马上结束,那么在创建线程时可以将其设置为守护线程 如果你希望在主线程结束后子线程继续工作,等子线程结束后再让JVM进程结束,那么就将子线程设置为用户线程

线程的状态

Java线程有6个状态,通过源码得知:
public enum State {
    NEW,
    RUNNABLE,
    BLOCKED,
    WAITING,
    TIMED_WAITING,
    TERMINATED;
}
//Thread中的State
  • NEW 代表线程创建成功但是尚未启动(还没有执行start方法)。Q:能否重复调用start方法?A:不能。在第一次调用start方法后,线程内部的threadStatus的值就会改变,再次调用会抛出异常。
  • RUNNABLE 表示当前线程正在运行中。处于RUNNABLE状态的线程在Java虚拟机中运行,也有可能在等待其他系统资源。
  • BLOCKED 阻塞状态。处于BLOCKED状态的线程正等待锁的释放以进入同步区。
  • WAITING 等待状态。处于等待状态的线程变成RUNNABLE状态需要其他线程唤醒。如下三个方法会使线程进入等待状态
      1. Object.wait():使当前线程处于等待状态直到另一个线程唤醒它;
      1. Thread.join():等待线程执行完毕,底层调用的是Object实例的wait方法;
      1. LockSupport.park():除非获得调用许可,否则禁用当前线程进行线程调度。
  • TIMED_WAITING 超时等待状态。线程等待一个具体的时间,时间到后会被自动唤醒。调用如下方法会使线程进入超时等待状态:
      1. Thread.sleep(long millis):使当前线程睡眠指定时间;
      1. Object.wait(long timeout):线程休眠指定时间,等待期间可以通过notify()/notifyAll()唤醒;
      1. Thread.join(long millis):等待当前线程最多执行millis毫秒,如果millis为0,则会一直执行;
      1. LockSupport.parkNanos(long nanos): 除非获得调用许可,否则禁用当前线程进行线程调度指定时间;
      1. LockSupport.parkUntil(long deadline):同上,也是禁止线程进行调度指定时间;
  • TERMINATED 终止状态。此时线程已执行完毕。
notion image
 

共享模型之管程

并发带来的安全隐患

线程安全问题是指当 多个线程 同时读写 一个共享资源 并且 没有任何同步措施 时,导致出现脏数据或者其他不可预见的结果的问题。

synchronized

synchronized 实际是 用对象锁 保证了临界区内代码的 原子性 ,临界区内的代码对外不会被线程切换所打断。

用法

synchronized 关键字最主要的三种使用方式:
1.修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
synchronized void method() {
  //业务代码
}
2.修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份 )。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁
synchronized static void method() {
//业务代码
}
3.修饰代码块 :指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁
synchronized(this) {
  //业务代码
}
总结:
  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。
  • synchronized 关键字加到实例方法上是给对象实例上锁。
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!

原理

JAVA对象头

对象在内存中分为三块区域: 对象头 、示例数据、对齐填充。
对象头 包括两个部分: MarkWord 、类型指针。
synchronized 的加锁就是对同一个对象的对象头中的MarkWord中的变量进行CAS操作。

MarkWord

MarkWord用于存储对象自身的运行时数据,如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等等。
占用内存大小与虚拟机位长一致。
notion image

Monitor

Monitor是由操作系统提供的一个对象,可以理解为我们所说的锁。
每个java对象都可以关联一个Monitor对象,如果使用synchronized给对象加锁(重量级)之后,该对象的 Mark Word就被设置指向了Monitor对象的指针
notion image
 
  1. 如图 Thread-2 首先进入了临界区代码, MarkWord 与 obj 关联, Thread-2 成为了 Monitor 的Owner。
  1. Thread-1 和 Thread-3 相继进入临界区代码,都发现Owner已经有人,则会加入 Monitor 的EntryList中阻塞。
  1. Thread-2 离开后阻塞的线程重新 非公平 竞争。

升级机制

1️⃣
偏向锁
适用于总是同一线程获得锁的情况。
notion image
 
还有一些点需要注意:
  • 当锁对象调用了hashcode方法时,会撤销偏向锁(因为MarkWord里需要空间存hashcode)
  • 轻量级锁的hashcode存在线程栈桢的锁记录里
  • 重量级锁会存在monitor对象里
  • 偏向锁默认是延迟的,程序开启几秒后才会生效,可以设置参数来禁用延迟。
  • 偏向锁也可以被禁用,禁用的原因可以是因为 在竞争状态下,偏向锁十分累赘 。
Q:为什么偏向锁在竞争状态下十分累赘呢? A:偏向锁升级为轻量级锁时会执行如下操作 1.停止所有拥有锁的线程遍历线程栈 2.一个个修复所记录和MarkWord唤醒 这些操作开销很大。
2️⃣
轻量级锁
notion image
 
在代码进入同步块的时候,首先当前线程的 栈帧中 会建立一个名为 锁记录(Lock Record) 的空间,然后 拷贝对象头中的Mark Word复制到锁记录中 。
拷贝成功后,虚拟机将 使用CAS操作 尝试将对象的 Mark Word替换为指向Lock Record的指针 ,并将 Lock Record里的owner指针指向对象的Mark Word。
notion image
 
释放 时,同样用CAS还原原来的操作
  • 如果没有升级锁,则成功把原来改变的东西还回去
  • 如果升级了,这时 MarkWord指向的是Monitor对象 那么CAS失败,进入重量级锁的流程
3️⃣
重量级锁
见synchronized原理

批量重偏向

以class为单位,每个class维护一个偏向锁撤销计数器,每次撤销偏向锁,计数器+1。 到20时,将所有对象进行重偏向(将class里的epoch++,这样子所有对象创建时获得的epoch都比最新的小1)。 线程发现epoch不是最新的,就不撤销偏向锁,直接CAS改ID。

批量撤销

在重偏向的基础上,第三者过来,又撤销,到40次的时候,对象全部撤销偏向锁。

锁消除

Java中的JIT技术会对对象进行逃逸分析 (分析对象动态作用域) 同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器就会取消对这部分代码的同步。

wait notify

  • obj.wait() 让进入 object 监视器的线程到 waitSet 等待
  • obj.notify() 在 object 上正在 waitSet 等待的线程中挑一个唤醒
  • obj.notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒
  • wait() 方法会释放对象的锁,进入 WaitSet 等待区,从而让其他线程就机会获取对象的锁。无限制等待,直到被notify 为止
  • wait(long n) 有时限的等待, 到 n 毫秒后结束等待,或是未到等待时间被 notify
notion image

虚假唤醒

可以理解为唤醒错了人。如下可以防范虚假唤醒。
synchronized (obj){
    while (条件不满足) {
        obj.wait();
    }
}

Park Unpark

LockSupport 提供,作用分别是将线程wait和唤醒,与 wait notify 十分相似。

不同

  • waitnotifynotifyAll 必须配合 Object Monitor 一起使用,而 parkunpark 不必
  • park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不那么【精确】
  • park & unpark 可以先unpark,而 wait & notify 不能先 notify

原理

每个线程里都有一个 Parker 对象,由三部分组成:_counter    _cond    _mutex
_counter只有两种情况:0、1。
  • park() 方法会进行如下步骤
      1. 检查_counter是否为1,如果是,继续线程不会wait。
      1. 如果为0,获得_mutex互斥锁
      1. 线程进入_mutex互斥锁的等待队列_cond
      1. 无论wait与否,都要将_counter设置为0
  • unpark() 会进行如下步骤
      1. 设置_counter为1(如果线程没有wait则不会执行接下来的操作)
      1. 唤醒线程
      1. 设置_counter为0

线程问题

线程死锁

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。
死锁的产生必须具备以下四个条件:
  • 互斥条件即资源同时只由一个线程占用。如果线程请求一个已经被占用的资源,则请求者只能等待该资源被释放。
  • 请求并持有指至少持有了一个资源的线程请求了一个被占有的新资源,则该线程会被阻塞,同时不释放自己已经持有的资源
  • 不可剥夺被使用的资源在使用完之前不能被其他线程抢占
  • 环路等待指在发生死锁时,必然存在一个线程→资源的环形链。

如何避免线程死锁

只需要破坏掉至少一个构成死锁的必要条件,但是现在只有
  • 请求并持有
  • 环路等待条件
是可以被破坏的。
造成死锁的原因其实和申请资源的顺序有很大关系,使用资源申请的有序性原装则就可以避免死锁(但是容易造成饥饿)。
该原则破坏了资源的请求并持有环路等待条件
资源申请的有序性原装则 :指假如线程A和线程B都需要资源1,2,3,...,n时,对资源进行排序,线程A和线程B只有在获取了资源n-1时才能去获取资源n。

线程活锁

活锁就是指在两个线程互相改变对方的结束条件,最后谁也无法结束。

线程饥饿

指线程总是得不到cpu调度,但是有不会结束。
上文说到资源申请的有序性原装能解决死锁,但是容易造成饥饿。

ReentrantLock

对比synchronized

相对于synchronized它具备如下特点
  • 可中断
  • 可以设置超时时间
  • 可以设置为公平锁
  • 支持多个条件变量
synchronized 一样,都支持可重入

基本语法

//获得锁
reentrantLock.lock(); try {
// 临界区
} finally { // 释放锁
reentrantLock.unlock(); }

可中断

使用 lock() 方法无法实现可打断,需要使用 lock.lockInterruptibly() 或者 lock.tryLock() 方法,使用方法时:
  • 如果没有竞争,那么获得锁,就像 lock 一样。
  • 如果有竞争,进入阻塞队列。并且可以被打断。

公平锁

我们可以通过构造函数来决定 ReentrantLock 是否是公平锁(默认false,不公平)。
public ReentrantLock(boolean fair){
    sync = fair ? new FairSync() : new NonfairSync();
}

条件变量(Condition)

等同于 synchronized 的 waitset ReentrantLock的条件变量比synchronized强大之处在于,它是支持多个条件变量的。
直接看例子吧
static ReentrantLock lock = new ReentrantLock();
static Condition waitCigaretteQueue = lock.newCondition();
static Condition waitbreakfastQueue = lock.newCondition();
static volatile boolean hasCigrette = false;
static volatile boolean hasBreakfast = false;

public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
        try {
            lock.lock();
            while (!hasCigrette) {
                try {
                    waitCigaretteQueue.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("等到了它的烟");
        } finally {
            lock.unlock();
        }
    }).start();
    new Thread(() -> {
        try {
            lock.lock();
            while (!hasBreakfast) {
                try {
                    waitbreakfastQueue.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("等到了它的早餐");
        } finally {
            lock.unlock();
        }
    }).start();
    sleep(1);
    sendBreakfast();
    sleep(1);
    sendCigarette();
}

private static void sendBreakfast() {
    lock.lock();
    try {
        System.out.println("送早餐来了");
        hasBreakfast = true;
        waitbreakfastQueue.signal();
    } finally {
        lock.unlock();
    }
}

private static void sendCigarette() {
    lock.lock();
    try {
        System.out.println("送烟来了");
        hasCigrette = true;
        waitCigaretteQueue.signal();
    } finally {
        lock.unlock();
    }
}

共享模型之内存

notion image

可见性

是由JMM的造成的。
指不同线程之间的工作缓存的可见性。可以使用 volatile 关键字来解决( synchronized 也可以啦)
volatile 可以保证可见性,但是却 无法保证原子性 synchronized 可以保证可见性和原子性,但是属于重量级操作,性能低

重排序

为了提高性能,编译器和处理器常常会对指令做重排。
指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。
指令重排一般分为以下三种:
  • 编译器优化重排编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令并行重排现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
  • 内存系统重排由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

重排序的问题

int num = 0;

// volatile 修饰的变量,可以禁用指令重排 volatile boolean ready = false; 
// 可以防止变量之前的代码被重排序
boolean ready = false; 

// 线程1 执行此方法
public void actor1(I_Result r) {
     if(ready) {
        r.r1 = num + num;
     } 
     else {
        r.r1 = 1;
     }
}
// 线程2 执行此方法
public void actor2(I_Result r) {
     num = 2;
     ready = true;
}
正常来看结果要么是1,要么是4,但是如果线程2的两条指令重排序了,那么还有可能是0,与代码逻辑不符。

禁止重排序

使用volatile关键字。(详情见原理)

volatile

原理

volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
  • volatile 变量的写指令后会加入写屏障。(保证写屏障之前的写操作, 都能同步到主存中)
  • volatile 变量的读指令前会加入读屏障。(保证读屏障之后的读操作, 都能读到主存的数据)

保证可见性

  • 当一个线程对volatile修饰的变量进行写操作时,JMM会立即把该线程对应的本地内存中的共享变量的值刷新到主内存
  • 当一个线程对volatile修饰的变量进行读操作时,JMM会把立即该线程对应的本地内存置为无效从主内存中读取共享变量的值。

禁止指令重排序

notion image

volatile不能解决原子性问题

写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读, 跑到它前面去
有序性的保证也只保证了本线程内相关代码不被重排序

共享模型之无锁

CAS(compaer and swap/set)

CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。
CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。
更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

与volatile的关系

由于CAS操作需要获取共享变量的最新值,所以用 volatile 来保证变量的可见性

为什么使用CAS的无锁情况效率更高呢?

因为无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时 候,发生上下文切换,进入阻塞。

CAS的特点

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系。
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
  • CAS 体现的是无锁并发无阻塞并发

JUC工具包

原子整数

  • AtomicBoolean
  • AtomicInteger
  • AtomicLong
AtomicInteger i = new AtomicInteger(0);
// 获取并自增(i = 0, 结果 i = 1, 返回 0),类似于 i++ 
    System.out.println(i.getAndIncrement());
// 自增并获取(i = 1, 结果 i = 2, 返回 2),类似于 ++i 
    System.out.println(i.incrementAndGet());
// 自减并获取(i = 2, 结果 i = 1, 返回 1),类似于 --i 
    System.out.println(i.decrementAndGet());
// 获取并自减(i = 1, 结果 i = 0, 返回 1),类似于 i--
    System.out.println(i.getAndDecrement());
// 获取并加值(i = 0, 结果 i = 5, 返回 0) 
    System.out.println(i.getAndAdd(5));
// 加值并获取(i = 5, 结果 i = 0, 返回 0) 
    System.out.println(i.addAndGet(-5));
// 获取并更新(i = 1, p 为 i 的当前值, 结果 i = 2, 返回 1) // 其中函数中的操作能保证原子,但函数需要无副作用 
    System.out.println(i.getAndUpdate(p -> p * 2));
// 更新并获取(i = 2, p 为 i 的当前值, 结果 i = 4, 返回 4) // 其中函数中的操作能保证原子,但函数需要无副作用 
    System.out.println(i.updateAndGet(p -> p * 2));
// 获取并计算(i = 0, p 为 i 的当前值, x 为参数1, 结果 i = 10, 返回 0) 
// 其中函数中的操作能保证原子,但函数需要无副作用 
// getAndUpdate 如果在 lambda 中引用了外部的局部变量,要保证该局部变量是 final 的 
// getAndAccumulate 可以通过 参数1 来引用外部的局部变量,但因为其不在 lambda 中因此不必是 final 
    System.out.println(i.getAndAccumulate(10, (p, x) -> p + x));
// 计算并获取(i = 10, p 为 i 的当前值, x 为参数1, 结果 i = 0, 返回 0) 
// 其中函数中的操作能保证原子,但函数需要无副作用 
    System.out.println(i.accumulateAndGet(-10, (p, x) -> p + x));
updateAndGet的逻辑
while(true){
    int prev=i.get();
    int next=prev*10;
    if(i.compareAndSet(prev,next)){
        break;
    }
}

原子引用

  • AtomicReference
  • AtomicMarkableReference
  • AtomicStampedReference

ABA问题

当其他变量把共享变量从A改为B,又改回A。
CAS判断:预期值A,最新值A,没问题。(但是实际上共享变量已经被修改过了)
CAS只能判断最新值与预期值是否相等,但是不能判断共享变量是否被其他变量修改
解决方案
  1. 为共享变量加上版本号,每次被修改就+1。AtomicStampedReference 可以完成。
    1. static AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
      public static void main(String[] args) throws InterruptedException { 
          log.debug("main start..."); 
          // 获取值 A 
          String prev = ref.getReference(); 
          // 获取版本号 
          int stamp = ref.getStamp(); 
          log.debug("版本 {}", stamp); 
          // 如果中间有其它线程干扰,发生了 ABA 现象 
          other(); 
          sleep(1);
          // 尝试改为 C 
          log.debug("change A->C {}", ref.compareAndSet(prev, "C", stamp, stamp + 1));
      }
      private static void other() { 
          new Thread(() -> { 
              log.debug("change A->B {}", ref.compareAndSet(ref.getReference(), "B", 
                                                              ref.getStamp(), ref.getStamp() + 1));
              log.debug("更新版本为 {}", ref.getStamp());
      }, "t1").start(); 
          sleep(0.5); 
          new Thread(() -> { 
              log.debug("change B->A {}", ref.compareAndSet(ref.getReference(), "A", 
                                                              ref.getStamp(), ref.getStamp() + 1));
              log.debug("更新版本为 {}", ref.getStamp()); 
          }, "t2").start(); }
      15:41:34.891 c.Test36 [main] - main start... 
      15:41:34.894 c.Test36 [main] - 版本 0 
      15:41:34.956 c.Test36 [t1] - change A->B true 
      15:41:34.956 c.Test36 [t1] - 更新版本为 1 
      15:41:35.457 c.Test36 [t2] - change B->A true 
      15:41:35.457 c.Test36 [t2] - 更新版本为 2 
      15:41:36.457 c.Test36 [main] - change A->C false
  1. 当我们只关心有没有被更改过时,并不关心更改了几次时,就不需要版本号了。使用一个Boolean来代替版本号。AtomicMarkableReference 可以完成。

原子数组

  • AtomicIntegerArray
  • AtomicLongArray
  • AtomicReferenceArray

字段更新器

  • AtomicReferenceFieldUpdater // 域 字段
  • AtomicIntegerFieldUpdater
  • AtomicLongFieldUpdater
(配合 volatile 修饰的字段使用,否则会出现 异常)

原子累加器

  • DoubleAccumulator
  • DoubleAdder
  • LongAccumulator
  • LongAdder
在高并发情况下,LongAdder的累加性能要远远大于AtomicLong
原理:原子类型累加器其实是应用了热点分离思想
  • 将竞争的数据进行分解成多个单元,在每个单元中分别进行数据处理。
  • 各单元处理完成之后,通过Hash算法进行计算求和,从而得到最终的结果。
  • Thread-0累加Cell[0]而Thread-1累加Cell[1]…最后将结果汇总,减少了失败自旋,从而提升性能。

Unsafe对象

Unsafe 对象提供了非常底层的,操作内存、线程的方法,Unsafe 对象不能直接调用,只能通过反射获得。

共享模型之不可变

Loading Comments...