Java并发编程:二

共享模型之管程

10. 重新理解线程状态转换

情况一:NEW ---> RUNNABLE

  • 当调用 t.start() 方法时,线程由 NEW 转换为 RUNNABLE 状态

情况二:RUNNABLE <---> WAITING

t 线程 synchronized(obj) 获取了对象锁后
  • 调用 obj.wait() 方法时,t 线程RUNNABLE --> WAITING
  • 调用 obj.notify() obj.notifyAll() t.interrupt()
    • 竞争锁成功,t 线程 WAITING --> RUNNABLE
    • 竞争锁失败,t 线程 WAITING --> BLOCKED

情况三:RUNNABLE <---> WAITING

  • 当前线程调用 t.join() 方法时,当前线程RUNNABLE --> WAITING
    • 注意是当前线程t 线程对象的监视器上等待
  • t 线程运行结束,或 其他线程 调用了当前线程interrupt() 时,当前线程WAITING --> RUNNABLE

情况四:RUNNABLE <---> WAITING

  • 当前线程调用 LockSupport.park() 方法会让当前线程从 RUNNABLE --> WAITING
  • 其他线程 调用 LockSupport.unpark(目标线程) 或调用了 目标线程 的 interrupt() ,会让目标线程从 WAITING --> RUNNABLE

情况五:RUNNABLE <---> TIMED_WAITING

t 线程 synchronized(obj) 获取了对象锁后
  • 调用 obj.wait(long n) 方法时,t 线程RUNNABLE --> TIMED_WAITING
  • t 线程等待时间超过了 n 毫秒,或调用 obj.notify() obj.notifyAll() t.interrupt()
    • 竞争锁成功,t 线程 TIMED_WAITING --> RUNNABLE
    • 竞争锁失败,t 线程 TIMED_WAITING --> BLOCKED

 情况六:RUNNABLE <---> TIMED_WAITING

  • 当前线程调用 t.join(long n) 方法时,当前线程RUNNABLE --> TIMED_WAITING
    • 注意是当前线程t 线程对象的监视器上等待
  • 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程interrupt() 时,当前线程从TIMED_WAITING --> RUNNABLE

 情况七:RUNNABLE <---> TIMED_WAITING

  • 当前线程调用 Thread.sleep(long n) ,当前线程从 RUNNABLE --> TIMED_WAITING
  • 当前线程等待时间超过了 n 毫秒,当前线程 TIMED_WAITING --> RUNNABLE

情况八:RUNNABLE <---> TIMED_WAITING

  • 当前线程调用 LockSupport.parkNanos(long nanos) LockSupport.parkUntil(long millis) 时,当前线RUNNABLE --> TIMED_WAITING
  • 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,或是等待超时,会让目标线程从TIMED_WAITING--> RUNNABLE

情况九:RUNNABLE <--->BOLCKED

  • t 线程 synchronized(obj) 获取了对象锁时如果竞争失败,从 RUNNABLE --> BLOCKED
  • obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED 的线程重新竞争,如果其中 t 线程竞争成功,从 BLOCKED --> RUNNABLE ,其它失败的线程仍然 BLOCKED

情况十:RUNABLE <---> TERMINATED

当前线程所有代码运行完毕,进入 TERMINATED

11. 多把锁

如果两个线程要做的事互不干扰,但是两个线程锁同一个对象的话,就会造成不必要的阻塞。

解决办法是使用多把锁

class BigRoom {
 private final Object studyRoom = new Object();
 private final Object bedRoom = new Object();
 public void sleep() {
 synchronized (bedRoom) {
 log.debug("sleeping 2 小时");
 Sleeper.sleep(2);
 }
 }
 public void study() {
 synchronized (studyRoom) {
 log.debug("study 1 小时");
 Sleeper.sleep(1);
 }
 }
}
将锁的粒度细分
  • 好处,是可以增强并发度
  • 坏处,如果一个线程需要同时获得多把锁,就容易发生死锁

12. 活跃性

死锁

一个线程如果需要同时获取多把锁,就容易发生死锁
t1 线程 获得 A 对象 锁,接下来想获取 B 对象 的锁 , t2 线程 获得 B 对象 锁,接下来想获取 A 对象 的锁,这时就会导致 t1,t2 线程同时陷入阻塞,且无法结束

定位死锁

检测死锁可以使用 jconsole 工具,或者使用 jps 定位进程 id ,再用 jstack 定位死锁:
Found one Java-level deadlock:
=============================
"Thread-1":
 waiting to lock monitor 0x000000000361d378 (object 0x000000076b5bf1c0, a java.lang.Object),
 which is held by "Thread-0"
"Thread-0":
 waiting to lock monitor 0x000000000361e768 (object 0x000000076b5bf1d0, a java.lang.Object),
 which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================
"Thread-1":
 at thread.TestDeadLock.lambda$main$1(TestDeadLock.java:28)
 - waiting to lock <0x000000076b5bf1c0> (a java.lang.Object)
 - locked <0x000000076b5bf1d0> (a java.lang.Object)
 at thread.TestDeadLock$$Lambda$2/883049899.run(Unknown Source)
 at java.lang.Thread.run(Thread.java:745)
"Thread-0":
 at thread.TestDeadLock.lambda$main$0(TestDeadLock.java:15)
 - waiting to lock <0x000000076b5bf1d0> (a java.lang.Object)
 - locked <0x000000076b5bf1c0> (a java.lang.Object)
 at thread.TestDeadLock$$Lambda$1/495053715.run(Unknown Source)
 at java.lang.Thread.run(Thread.java:745)
Found 1 deadlock.
  • 避免死锁要注意加锁顺序
  • 另外如果由于某个线程进入了死循环,导致其它线程一直等待,对于这种情况 linux 下可以通过 top 先定位到CPU 占用高的 Java 进程,再利用 top -Hp 进程id 来定位是哪个线程,最后再用 jstack 排查

哲学家就餐问题

有五位哲学家,围坐在圆桌旁。
  • 他们只做两件事,思考和吃饭,思考一会吃口饭,吃完饭后接着思考。
  • 吃饭时要用两根筷子吃,桌上共有 5 根筷子,每位哲学家左右手边各有一根筷子。
  • 如果筷子被身边的人拿着,自己就得等待

筷子类

class Chopstick {
 String name;
 public Chopstick(String name) {
 this.name = name;
 }
 @Override
 public String toString() {
 return "筷子{" + name + '}';
 }
}

哲学家类

class Philosopher extends Thread {
 Chopstick left;
 Chopstick right;
 public Philosopher(String name, Chopstick left, Chopstick right) {
 super(name);
 this.left = left;
 this.right = right;
 }
 private void eat() {
 log.debug("eating...");
 Sleeper.sleep(1);
 }
 
 @Override
 public void run() {
 while (true) {
 // 获得左手筷子
 synchronized (left) {
 // 获得右手筷子
 synchronized (right) {
 // 吃饭
 eat();
 }
 // 放下右手筷子
 }
 // 放下左手筷子
 }
 }
}

就餐

Chopstick c1 = new Chopstick("1");
Chopstick c2 = new Chopstick("2");
Chopstick c3 = new Chopstick("3");
Chopstick c4 = new Chopstick("4");
Chopstick c5 = new Chopstick("5");
new Philosopher("苏格拉底", c1, c2).start();
new Philosopher("柏拉图", c2, c3).start();
new Philosopher("亚里士多德", c3, c4).start();
new Philosopher("赫拉克利特", c4, c5).start();
new Philosopher("阿基米德", c5, c1).start();

运行这段代码之后,没过多久便发现无法继续往下执行了,原因便是发生了死锁。五个线程各自拥有一支筷子,在等待别人筷子的同时又不愿意放弃自己的筷子。

这种线程没有按预期结束,执行不下去的情况,归类为【活跃性】问题,除了死锁以外,还有活锁和饥饿者两种情况

活锁

活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如
public class TestLiveLock {
 static volatile int count = 10;
 static final Object lock = new Object();
 public static void main(String[] args) {
 new Thread(() -> {
 // 期望减到 0 退出循环
 while (count > 0) {
 sleep(0.2);
 count--;
 log.debug("count: {}", count);
 }
 }, "t1").start();
 new Thread(() -> {
 // 期望超过 20 退出循环
 while (count < 20) {
 sleep(0.2);
 count++;
 log.debug("count: {}", count);
 }
 }, "t2").start();
 }
}

两个线程都在改变对方的结束条件,最后谁也无法结束。

可以加入随机的睡眠时间解决

饥饿

很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束

13. ReentrantLock

相对于 synchronized 它具备如下特点
  • 可中断
  • 可以设置超时时间
  • 可以设置为公平锁
  • 支持多个条件变量
synchronized 一样,都支持可重入
基本语法
// 获取锁
reentrantLock.lock();
try {
 // 临界区
} finally {
 // 释放锁
 reentrantLock.unlock();
}

可重入

可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁
如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住

可打断

如果想让线程获取锁失败后的阻塞阶段可被打断,可使用 lockInterruptibly 方法

ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
     log.debug("启动...");
     try {
         lock.lockInterruptibly();
     } catch (InterruptedException e) {
         e.printStackTrace();
         log.debug("等锁的过程中被打断");
         return;
     }
     try {
         log.debug("获得了锁");
     } finally {
         lock.unlock();
     }
}, "t1");

lock.lock();
log.debug("获得了锁");
t1.start();
try {
     sleep(1);
     t1.interrupt();
     log.debug("执行打断");
} finally {
     lock.unlock();
}

运行结果

18:02:40.520 [main] c.TestInterrupt - 获得了锁
18:02:40.524 [t1] c.TestInterrupt - 启动... 
18:02:41.530 [main] c.TestInterrupt - 执行打断
java.lang.InterruptedException 
 at 
java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireInterruptibly(AbstractQueuedSynchr
onizer.java:898) 
 at 
java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchron
izer.java:1222) 
 at java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:335) 
 at cn.itcast.n4.reentrant.TestInterrupt.lambda$main$0(TestInterrupt.java:17) 
 at java.lang.Thread.run(Thread.java:748) 
18:02:41.532 [t1] c.TestInterrupt - 等锁的过程中被打断

如上所示,原本 t1 线程在获取锁失败后会进入 WAITING 状态阻塞,但是在这里可以调用 interrupt 方法打断 t1 线程的等待

如果是不可打断的 lock 方法,那么即使使用了 interrupt 方法也不会打断等待 


 锁超时

trylock() 方法

  • 返回值类型为 Boolean
  • 无参:无法获取锁后立即判定失败,不会阻塞等待
  • 带参:可设置等待时间与时间单位,在等待时间内会重复尝试获取锁,超时后失败
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
     log.debug("启动...");
     if (!lock.tryLock()) {
         log.debug("获取立刻失败,返回");
         return;
     }
     try {
         log.debug("获得了锁");
     } finally {
         lock.unlock();
     }
}, "t1");
lock.lock();
log.debug("获得了锁");
t1.start();
try {
     sleep(2);
} finally {
     lock.unlock();
}

运行结果

18:15:02.918 [main] c.TestTimeout - 获得了锁
18:15:02.921 [t1] c.TestTimeout - 启动... 
18:15:02.921 [t1] c.TestTimeout - 获取立刻失败,返回

可使用 trylock 方法解决哲学家问题,获取右手筷子失败后不会阻塞等待,而是继续执行之后的代码,这样就可以释放左手筷子,以防止死锁的产生

public void run() {
     while (true) {
         // 尝试获得左手筷子
         if (left.tryLock()) {
             try {
                 // 尝试获得右手筷子
                 if (right.tryLock()) {
                     try {
                         eat();
                     } finally {
                         right.unlock();
                     }
                 }
             } finally {
                 left.unlock();
             }
         }
     }
 }

公平锁

ReentrantLock 默认是不公平的,但可以使用带参构造来让它成为公平锁
    /**
     * Creates an instance of {@code ReentrantLock} with the
     * given fairness policy.
     *
     * @param fair {@code true} if this lock should use a fair ordering policy
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

条件变量

synchronized 中也有条件变量, 当条件不满足时进入 waitSet 等待
ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比
  • synchronized 是那些不满足条件的线程都在一间休息室等消息
  • ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤

使用要点

  • await 前需要获得锁
  • await 执行后,会释放锁,进入 conditionObject 等待
  • await 的线程被唤醒(或打断、或超时)后重新竞争 lock
  • 竞争 lock 锁成功后,从 await 后继续执行

共享模型之内存

1. Java内存模型

JMM Java Memory Model ,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
JMM 体现在以下几个方面
  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响
  • 主内存(Main Memory): 主内存是所有线程共享的内存区域,用于存储共享变量的值。在主内存中,共享变量的值被保持一致。

  • 工作内存(Working Memory): 每个线程拥有自己的工作内存,用于存储共享变量的副本。线程在工作内存中执行读写操作,而不是直接在主内存中进行。


2. 可见性

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
 Thread t = new Thread(()->{
 while(run){
 // ....
 }
 });
 t.start();
 sleep(1);
 run = false; // 线程t不会如预想的停下来
}

当我们运行后,发现这个线程没有如预想中的那样正确停止下来

原因:

  1. t 线程开始运行,从主存中读取 run 的值
  2. 运行一定次数后,JIT 对其进行优化,将 run 的值拷贝至 高速缓存 中
  3. t 线程不再去主存中读取 run 值,转在缓存中读取
  4. main 线程修改 run 值为 false,但 t 线程仍然从缓存中读取 run 值,导致了程序没有正确停止

解决办法

使用 volatile 关键字。

它可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存

可见性 VS 原子性

可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可
见, 不能保证原子性,仅用在一个写线程,多个读线程的情况
而原子性是指字节码指令的不可分割,即不会因线程的上下文切换而造成线程安全问题

3. 有序性

指令重排

JVM 会在不影响正确性的前提下,调整语句的执行顺序
指令重排序优化
事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这 5 个阶段
在不改变程序结果的前提下,这些指令的各个阶段可以通过 重排序 组合 来实现 指令级并行 ,这一技术在 80's 中叶到 90's 中叶占据了计算架构的重要地位。
多级指令流水线
现代 CPU 支持 多级指令流水线 ,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线
禁止指令重排
volatile 修饰的变量,可以禁用指令重排

4. happens-before

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则, JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() t1.join()等待它结束)
  • 线程 t1 打断 t2interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted t2.isInterrupted
  • 对变量默认值(0falsenull)的写,对其它线程对该变量的读可见
  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z 

5. 习题

实现1:
// 问题1:为什么加 final
// 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例
public final class Singleton implements Serializable {
 // 问题3:为什么设置为私有? 是否能防止反射创建新的实例?
 private Singleton() {}
 // 问题4:这样初始化是否能保证单例对象创建时的线程安全?
 private static final Singleton INSTANCE = new Singleton();
 // 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由
 public static Singleton getInstance() {
 return INSTANCE;
 }
 public Object readResolve() {
 return INSTANCE;
 }
}
  • 问题1:为什么加 final      
    • 避免产生子类重写父类方法的隐患
  • 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例
    • 重写 Serializable 接口的 readObject 方法,返回唯一单例即可
  • 问题3:为什么设置为私有? 是否能防止反射创建新的实例?
    • 无参构造设置为私有是为了防止 new 新的对象。但是无法防止暴力反射
  • 问题4:这样初始化是否能保证单例对象创建时的线程安全?
    • 可以。静态变量引用对象的初始化是由 cinit 方法实现,由JVM保证线程安全
  • 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由

实现2

// 问题1:枚举单例是如何限制实例个数的
// 问题2:枚举单例在创建时是否有并发问题
// 问题3:枚举单例能否被反射破坏单例
// 问题4:枚举单例能否被反序列化破坏单例
// 问题5:枚举单例属于懒汉式还是饿汉式
// 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做
enum Singleton { 
 INSTANCE; 
}
  • 问题1:枚举单例是如何限制实例个数的
    • 枚举变量在底层都是由 final static 修饰的
  • 问题2:枚举单例在创建时是否有并发问题
    • 没有,静态变量的初始化由JVM实现
  • 问题3:枚举单例能否被反射破坏单例
    • 不能,枚举常量是在类加载时被创建的,且只会被创建一次,如果你尝试通过反射来创建枚举常量,通常会抛出一个IllegalArgumentExceptionExceptionInInitializerError异常
  • 问题4:枚举单例能否被反序列化破坏单例
    • 不会,枚举类实现了 Serializable 接口
  • 问题5:枚举单例属于懒汉式还是饿汉式
    • 饿汉式。在类加载时就会被初始化
  • 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做
    • ​​​​​​​枚举类可以加构造方法实现初始化逻辑

实现3

public final class Singleton {
 private Singleton() { }
 private static Singleton INSTANCE = null;
 // 分析这里的线程安全, 并说明有什么缺点
 public static synchronized Singleton getInstance() {
 if( INSTANCE != null ){
 return INSTANCE;
 } 
 INSTANCE = new Singleton();
 return INSTANCE;
 }
}

这个实现是线程安全的。缺点是每次调用 getInstance 方法都会先加锁,性能上较低


实现4:DCL

public final class Singleton {
     private Singleton() { }
     // 问题1:解释为什么要加 volatile ?
     private static volatile Singleton INSTANCE = null;
 
     // 问题2:对比实现3, 说出这样做的意义 
     public static Singleton getInstance() {
         if (INSTANCE != null) { 
             return INSTANCE;
         }
         synchronized (Singleton.class) { 
             // 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗
             if (INSTANCE != null) { // t2 
                 return INSTANCE;
             }
             INSTANCE = new Singleton(); 
             return INSTANCE;
         } 
     }
}
  • 问题1:解释为什么要加 volatile ?
    • ​​​​​​​防止发生指令重排
  • 问题2:对比实现3, 说出这样做的意义
    • 先在锁外判断是否已有实例,可以避免频繁加锁,提高性能
  • 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗
    • ​​​​​​​防止因线程上下文你切换而造成的线程安全问题

实现5
public final class Singleton {
 private Singleton() { }
 // 问题1:属于懒汉式还是饿汉式
 private static class LazyHolder {
 static final Singleton INSTANCE = new Singleton();
 }
 // 问题2:在创建时是否有并发问题
 public static Singleton getInstance() {
 return LazyHolder.INSTANCE;
 }
}
  • 问题1:属于懒汉式还是饿汉式
    • ​​​​​​​懒汉式。静态代码块会在类加载的时候执行且只执行一次。
  • 问题2:在创建时是否有并发问题
    • ​​​​​​​没有。只有当第一次调用 getInstance 方法时才会执行静态代码块中的内容。而静态变量的初始化是由JVM保证线程安全的

触发类加载:

  1. 当创建类的实例(对象)时,如果该类尚未加载,会触发类的加载。

  2. 当访问类的静态成员(静态字段或静态方法)时,会触发类的加载。

  3. 当使用Class.forName("ClassName")方法加载类时,会触发类的加载。


6. 本章小结

本章重点讲解了 JMM 中的
  • 可见性 - JVM 缓存优化引起
  • 有序性 - JVM 指令重排序优化引起
  • happens-before 规则
  • 原理方面
    • CPU 指令并行
    • volatile
  • 模式方面
    • 两阶段终止模式的 volatile 改进
    • 同步模式之 balking

共享模型之无锁

1. CAS 与 volatile

public void withdraw(Integer amount) {
     while(true) {
     // 需要不断尝试,直到成功为止
         while (true) {
             // 比如拿到了旧值 1000
             int prev = balance.get();
             // 在这个基础上 1000-10 = 990
             int next = prev - amount;
             /*
             compareAndSet 正是做这个检查,在 set 前,先比较 prev 与当前值
             - 不一致了,next 作废,返回 false 表示失败
             比如,别的线程已经做了减法,当前值已经被减成了 990
             那么本线程的这次 990 就作废了,进入 while 下次循环重试
             - 一致,以 next 设置为新值,返回 true 表示成功
             */
             if (balance.compareAndSet(prev, next)) {
                 break;
             }
         }
     }
}
其中的关键是 compareAndSet ,它的简称就是 CAS (也有 Compare And Swap 的说法),它必须是原子操作。
其实 CAS 的底层是 lock cmpxchg 指令( X86 架构),在单核 CPU 和多核 CPU 下都能够保证【比较 - 交换】的原子性。
在多核状态下,某个核执行到带 lock 的指令时, CPU 会让总线锁住,当这个核把此指令执行完毕,再开启总线。这个过程中不会被线程的调度机制所打断,保证了多个线程对内存操作的准确性,是原子的。

为什么无锁效率高

  • 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
  • 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会从 运行状态 进入 可运行状态 ,还是会导致上下文切换。
  • 因此,线程数最好不要大于CPU核数

CAS的特点

结合 CAS volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。
  • CAS是基于乐观锁的思想,认为不会有别的线程来干扰。如果发现实际与预期不符,就会再次重试
  • synchronized 是基于悲观锁的思想,以一种悲观的态度来防止一切数据冲突。它会以一种预防的姿态在修改数据之前加锁,在释放锁之前其他线程都无法干扰。
  • CAS 体现的是无锁并发、无阻塞并发
    • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
    • ​​​​​​​但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

2. 原子整数

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 = 0, p 为 i 的当前值, 结果 i = -2, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.getAndUpdate(p -> p - 2));
// 更新并获取(i = -2, p 为 i 的当前值, 结果 i = 0, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
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));

3. 原子引用

AtomicReference提供了一种原子性操作引用类型的机制,允许多线程同时访问和修改引用对象,而不需要额外的锁定机制。
class DecimalAccountSafeCas implements DecimalAccount {
     AtomicReference<BigDecimal> ref;
     public DecimalAccountSafeCas(BigDecimal balance) {
         ref = new AtomicReference<>(balance);
     }
     @Override
     public BigDecimal getBalance() {
         return ref.get();
     }
     @Override
     public void withdraw(BigDecimal amount) {
         while (true) {
             BigDecimal prev = ref.get();
             BigDecimal next = prev.subtract(amount);
             if (ref.compareAndSet(prev, next)) {
                 break;
             }
         }
     }
}

ABA问题及解决

static AtomicReference<String> ref = new AtomicReference<>("A");
public static void main(String[] args) throws InterruptedException {
     log.debug("main start...");
     // 获取值 A
     // 这个共享变量被它线程修改过?
     String prev = ref.get();
     other();
     sleep(1);
     // 尝试改为 C
     log.debug("change A->C {}", ref.compareAndSet(prev, "C"));
}
private static void other() {
     new Thread(() -> {
         log.debug("change A->B {}", ref.compareAndSet(ref.get(), "B"));
     }, "t1").start();
     sleep(0.5);
     new Thread(() -> {
         log.debug("change B->A {}", ref.compareAndSet(ref.get(), "A"));
     }, "t2").start();
}

输出

11:29:52.325 c.Test36 [main] - main start... 
11:29:52.379 c.Test36 [t1] - change A->B true 
11:29:52.879 c.Test36 [t2] - change B->A true 
11:29:53.880 c.Test36 [main] - change A->C true
主线程仅能判断出共享变量的值与最初值 A 是否相同,不能感知到这种从 A 改为 B 又 改回 A 的情况,如果主线程希望:
只要有其它线程【动过了】共享变量,那么自己的 cas 就算失败,这时,仅比较值是不够的,需要再加一个版本号
AtomicStampedReference提供了一种原子性操作引用对象以及附加的整数标记(stamp)的机制。这允许多线程同时访问和修改引用对象,同时可以跟踪引用对象的版本或标记,以解决一些并发问题。
有时候我们只关心引用变量有没有被修改过,而不关系修改过几次,这时候就可以使用 AtomicMarkableReference 类
AtomicMarkableReference 的典型用途是标记一个引用对象是否被访问过或者是否满足某些条件。它允许多线程同时访问和修改引用对象,并可以附加一个布尔标记,以标识对象的状态。 ​​​​​​​

4. 原子数组

  • AtomicIntegerArray
  • AtomicLongArray
  • AtomicReferenceArray
AtomicIntegerArray表示一个整数数组,其中的每个元素都可以原子地进行增减操作。这对于需要在多个线程之间共享整数数组并确保线程安全的情况非常有用。
import java.util.concurrent.atomic.AtomicIntegerArray;

public class AtomicIntegerArrayExample {
    public static void main(String[] args) {
        int[] intArray = new int[] { 1, 2, 3, 4, 5 };
        AtomicIntegerArray atomicArray = new AtomicIntegerArray(intArray);

        // Increment an element at index 2
        atomicArray.getAndIncrement(2);

        // Decrement an element at index 0
        atomicArray.getAndDecrement(0);

        // Get the value of an element at index 3
        int value = atomicArray.get(3);

        System.out.println("Updated Array: " + atomicArray);
        System.out.println("Value at index 3: " + value);
    }
}

在这个示例中,AtomicIntegerArray初始化为一个整数数组,然后使用原子操作对数组元素进行增减操作,而不会引起竞争条件。这提供了一种线程安全的方式来处理共享的整数数组。


5. 字段更新器

  • AtomicReferenceFieldUpdater // 字段
  • AtomicIntegerFieldUpdater
  • AtomicLongFieldUpdater
利用字段更新器,可以针对对象的某个域( Field )进行原子操作。但是只能配合 volatile 修饰的字段使用,否则会出现异常
Exception in thread "main" java.lang.IllegalArgumentException: Must be volatile type

6. 原子累加器

  • LongAdder
  • LongAccumulator
  • DoubleAdder
  • DoubleAccumulator
private static <T> void demo(Supplier<T> adderSupplier, Consumer<T> action) {
        T adder = adderSupplier.get();
        long start = System.nanoTime();
        List<Thread> ts = new ArrayList<>();
        // 4 个线程,每人累加 50 万
        for (int i = 0; i < 40; i++) {
            ts.add(new Thread(() -> {
                for (int j = 0; j < 500000; j++) {
                    action.accept(adder);
                }
            }));
        }
        ts.forEach(t -> t.start());
        ts.forEach(t -> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        long end = System.nanoTime();
        System.out.println(adder + " cost:" + (end - start)/1000_000);
    }
比较 AtomicLong LongAdder
for (int i = 0; i < 5; i++) {
 demo(() -> new LongAdder(), adder -> adder.increment());
}
for (int i = 0; i < 5; i++) {
 demo(() -> new AtomicLong(), adder -> adder.getAndIncrement());
}

结果

性能提升的原因很简单:LongAdder 会在有竞争时,设置多个累加单元, Therad-0 累加 Cell[0] ,而 Thread-1 累加Cell[1]... 最后将结果汇总。这样它们在累加时操作的不同的 Cell 变量,因此减少了 CAS 重试失败,从而提高性能。

7. LongAdder 原理

LongAdder 是并发大师 @author Doug Lea (大哥李)的作品,设计的非常精巧
LongAdder 类有几个关键域
// 累加单元数组, 懒惰初始化
transient volatile Cell[] cells;
// 基础值, 如果没有竞争, 则用 cas 累加这个域
transient volatile long base;
// 在 cells 创建或扩容时, 置为 1, 表示加锁
transient volatile int cellsBusy;

CAS锁

// 不要用于实践!!!
public class LockCas {
     private AtomicInteger state = new AtomicInteger(0);
     public void lock() {
         while (true) {
             if (state.compareAndSet(0, 1)) {
                 break;
             }
         }
     }
     public void unlock() {
         log.debug("unlock...");
         state.set(0);
     }
}

8. 伪共享

其中 Cell 即为累加单元
// 防止缓存行伪共享
@sun.misc.Contended
static final class Cell {
 volatile long value;
 Cell(long x) { value = x; }
 
 // 最重要的方法, 用来 cas 方式进行累加, prev 表示旧值, next 表示新值
 final boolean cas(long prev, long next) {
 return UNSAFE.compareAndSwapLong(this, valueOffset, prev, next);
 }
 // 省略不重要代码
}

从上图可知, CPU 与 内存的速度差异很大,所以需要靠预读数据至缓存来提升效率。
而缓存以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte 8 long)
缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中
CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效
因为 Cell 是数组形式,在内存中是连续存储的,一个 Cell 24 字节( 16 字节的对象头和 8 字节的 value ),因此缓存行可以存下 2 个的 Cell 对象。但是这样却存在问题:
  • Core-0中缓存了 Cell[0] 和 Cell[1]
  • Core-1中也缓存了 Cell[0] 和 Cell[1]
  • Core-0 要修改 Cell[0]
  • Core-1 要修改 Cell[1]
但是无论是Core-0修改了 Cell[0] 还是 Core-2 修改了 Cell[1] ,都会导致对方的缓存行失效。
而@sun.misc.Contended 就是 用来解决这个问题,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的padding,从而让 CPU 将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效

9. Unsafe

概述
Unsafe 对象提供了非常底层的,操作内存、线程的方法, Unsafe 对象不能直接调用,只能通过反射获得
        Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
        theUnsafe.setAccessible(true);
        Unsafe unsafe = (Unsafe) theUnsafe.get(null);

        System.out.println(unsafe);

       
Unsafe CAS 操作
利用 Unsafe 类对字段进行修改
        Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
        theUnsafe.setAccessible(true);
        Unsafe unsafe = (Unsafe) theUnsafe.get(null);

        //获取成员变量的偏移量
        long idOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("id"));
        long nameOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("name"));

        Teacher t = new Teacher();
        System.out.println(t);
        
        //使用 CAS 方法修改字段
        unsafe.compareAndSwapInt(t,idOffset,0,1);
        unsafe.compareAndSwapObject(t,nameOffset,null,"李四");

        System.out.println(t);

使用自定义的 AtomicData 实现之前线程安全的原子整数 Account 实现
class AtomicData {
        private volatile int data;
        static final Unsafe unsafe;
        static final long DATA_OFFSET;
        static {
            unsafe = UnsafeAccessor.getUnsafe();
            try {
                // data 属性在 DataContainer 对象中的偏移量,用于 Unsafe 直接访问该属性
                DATA_OFFSET = unsafe.objectFieldOffset(AtomicData.class.getDeclaredField("data"));
            } catch (NoSuchFieldException e) {
                throw new Error(e);
            }
        }
        public AtomicData(int data) {
            this.data = data;
        }
        public void decrease(int amount) {
            int oldValue;
            while(true) {
                // 获取共享变量旧值,可以在这一行加入断点,修改 data 调试来加深理解
                oldValue = data;
                // cas 尝试修改 data 为 旧值 + amount,如果期间旧值被别的线程改了,返回 false
                if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue - amount)) {
                    return;
                }
            }
        }
        public int getData() {
            return data;
        }
    }

10. 本章小结

  • CAS volatile
  • API
    • 原子整数
    • 原子引用
    • 原子数组
    • 字段更新器
    • 原子累加器
  • Unsafe
  • * 原理方面
    • LongAdder 源码
    • 伪共享
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值