公号:码农充电站pro

主页:https://codeshellme.github.io

8,Java 内存模型

JMM(Java Memory Model) 体现在以下几个方面:

  • 原子性:保证指令不会受到线程上下文切换的影响
    • 使用 synchronized
  • 可见性:保证指令不会受 cpu 缓存的影响
    • 使用 volatile(比较轻量) 或 synchronized(比较重量)
  • 有序性:保证指令不会受 cpu 指令并行优化的影响
    • volatile 可以禁止指令重排
    • synchronized 也可以禁止指令重排
    • volatile 关键字可以禁止该关键字修饰的变量,变量代码出现的地方之前的代码发生指令重排
volatile boolean ready = false;

/////////////////////////
num = 2;
ready = true; // 该代码之前的代码可以防止指令重排

volatile 的原理:其底层实现原理是内存屏障

  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令后会加入读屏障

1,可见性问题 volatile

可见性问题示例

// 退不出的循环
// main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止
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不会如预想的停下来
}

原因分析:

  • 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存
  • 在这里插入图片描述
  • 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中, 减少对主存中 run 的访问,提高效率
  • 在这里插入图片描述
  • 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量 的值,结果永远是旧值
  • 在这里插入图片描述

解决办法 volatile(易变关键字)

  • volatile 它可以用来修饰成员变量和静态成员变量,可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
  • 它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况
  • 注意 synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized 是属于重量级操作,性能相对更低

2,有序性问题-指令重排

JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,比如下面代码:

static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;
j = ...;

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是:

i = ...;
j = ...;

也可以是:

j = ...;
i = ...;

这种特性称之为『指令重排』,而 多线程下『指令重排』会影响正确性。为什么要有重排指令这项优化呢?从 CPU 执行指令的原理来理解一下吧,指令重拍可以增加指令的并行度

指令重排是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现。

一个例子:

int num = 0;
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;
}

I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?

  • 线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
  • 线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
  • 线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4

注意,下面两行代码可能会发生指令重排:

num = 2;
ready = true;

而变成:

ready = true;
num = 2;

这种情况下:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2。

此时就是指令重排导致了错误的结果

此时可以通过 volatile 关键字来修饰 ready 变量:

volatile boolean ready = false;

这样可以防止这两行代码发生重排序:

num = 2;
ready = true;

3,volatile 原理

volatile 可以解决可见性问题和指令重排问题。

volatile 的其底层实现原理是内存屏障

  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令后会加入读屏障

volatile 对可见性问题的处理:

写屏障保证在该屏障之前的,对共享变量的改动,都同步到主存当中

public void actor2(I_Result r) {
	num = 2;
	ready = true; // ready 是 volatile 赋值带写屏障
	// 写屏障位置
}

而读屏障保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据:

public void actor1(I_Result r) {
	// 读屏障位置
	// ready 是 volatile 读取值带读屏障
	if(ready) {
		r.r1 = num + num;
	} else {
		r.r1 = 1;
	}
}

volatile 对指令重排问题的处理:

写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后:

public void actor2(I_Result r) {
	num = 2;
	ready = true; // ready 是 volatile 赋值带写屏障
	// 写屏障位置
}

读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前:

public void actor1(I_Result r) {
	// 读屏障位置
	// ready 是 volatile 读取值带读屏障
	if(ready) {
		r.r1 = num + num;
	} else {
		r.r1 = 1;
	}
}

4,happens-before 规则

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。

变量都是指成员变量或静态成员变量

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
static int x;
static Object m = new Object();

new Thread(()->{
	synchronized(m) {
		x = 10;
	}
},"t1").start();

new Thread(()->{
	synchronized(m) {
		System.out.println(x);
	}
},"t2").start();
  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int x;

new Thread(()->{
	x = 10;
},"t1").start();

new Thread(()->{
	System.out.println(x);
},"t2").start();
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x;

x = 10;

new Thread(()->{
	System.out.println(x);
},"t2").start();
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
static int x;

Thread t1 = new Thread(()->{
	x = 10;
},"t1");

t1.start();
t1.join();

System.out.println(x);
  • 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
static int x;

public static void main(String[] args) {

	Thread t2 = new Thread(()->{
		while(true) {
			if(Thread.currentThread().isInterrupted()) {
				System.out.println(x);
				break;
			}
		}
	},"t2");
	
	t2.start();
	
	new Thread(()->{
		sleep(1);
		x = 10;
		t2.interrupt();
	},"t1").start();
	
	while(!t2.isInterrupted()) {
		Thread.yield();
	}
	
	System.out.println(x);
}
  • 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子
volatile static int x;
static int y;

new Thread(()->{
	y = 10;
	x = 20;
},"t1").start();

new Thread(()->{
	// x=20 对 t2 可见, 同时 y=10 也对 t2 可见
	System.out.println(x);
},"t2").start();

9,共享模型之无锁

1,问题:实现一个多线程取款

Account 接口:

public interface Account {

    // 获取余额
    Integer getBalance();

    // 取款
    void withdraw(Integer amount);

    /**
     * 方法内会启动 1000 个线程,每个线程做 -10 元 的操作
     * 如果初始余额为 10000 那么正确的结果应当是 0
     */
    static void demo(Account account) {
        List<Thread> ts = new ArrayList<>();
        long start = System.nanoTime();

        for (int i = 0; i < 1000; i++) {
            ts.add(new Thread(() -> {
                account.withdraw(10);
            }));
        }

        ts.forEach(Thread::start);
        ts.forEach(t -> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        long end = System.nanoTime();
        System.out.println(account.getBalance()
                + " cost: " + (end-start)/1000_000 + " ms");
    }
}

2,使用锁实现 synchronized

public class AccountSynchronized implements Account {
    private Integer balance;

    public AccountSynchronized(Integer balance) {
        this.balance = balance;
    }

    @Override
    public synchronized Integer getBalance() {
        return balance;
    }

    @Override
    public synchronized void withdraw(Integer amount) {
        balance -= amount;
    }

    public static void main(String[] args) {
        Account a = new AccountSynchronized(10000);
        Account.demo(a);
    }
}

3,使用无锁实现 Atomic

public class AccountAtomic implements Account {

    private AtomicInteger balance;

    public AccountAtomic(Integer balance) {
        this.balance = new AtomicInteger(balance);
    }

    @Override
    public Integer getBalance() {
        return balance.get();
    }

    @Override
    public void withdraw(nteger amount) {
        while (true) {
            int prev = balance.get();
            int next = prev - amount;
            
			// compareAndSet 是原子操作
			// 先比较再赋值
            if (balance.compareAndSet(prev, next)) {
                break;
            }
        }

        // 可以简化为下面的方法
        // addAndGet 是原子操作
        // balance.addAndGet(-1 * amount);
    }

    public static void main(String[] args) {
        Account a = new AccountAtomic(10000);
        Account.demo(a);
    }
}

其中的关键是 compareAndSet,它的简称就是 CAS (也有 Compare And Swap 的说法),它必须是原子操作

另外,AtomicInteger 中的 value 属性是 volatile 的。 CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果。

其实 CAS 的底层是 lock cmpxchg 指令(X86 架构),在单核 CPU 和多核 CPU 下都能够保证【比较-交换】的原子性。

在多核状态下,某个核执行到带 lock 的指令时,CPU 会让总线锁住,当这个核把此指令执行完毕,再开启总线。这个过程中不会被线程的调度机制所打断,保证了多个线程对内存操作的准确性,是原子的。

原子操作比锁的性能更高。

  • 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞
  • 线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速… 恢复到高速运行,代价比较大
  • 单CPU无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换

4,CAS 的特点

结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会
  • CAS 体现的是无锁并发、无阻塞并发,请仔细体会这两句话的意思
    • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
    • 如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

10,原子包装类

1,原子整数类

java.util.concurrent.atomic 包中提供了 3 个原子整数类:

  • AtomicBoolean
  • AtomicInteger
  • AtomicLong

以 AtomicInteger 为例:

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));

2,原子引用类

除了以上三种原子引用类型,想要保证其它的数据类型的原子操作,需要用到原子引用类型

  • AtomicReference
  • AtomicMarkableReference
  • AtomicStampedReference
1,AtomicReference
class DecimalAccountSafeCas implements DecimalAccount {
	AtomicReference<BigDecimal> ref;	// 对 BigDecimal 类型 进行原子包装
	
	public DecimalAccountSafeCas(BigDecimal balance) {
		ref = new AtomicReference<>(balance);
	}
}

原子引用-ABA问题:

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();
}

主线程仅能判断出共享变量的值与最初值 A 是否相同,不能感知到这种从 A 改为 B 又 改回 A 的情况.

如果主线程希望:只要有其它线程【动过了】共享变量,那么自己的 cas 就算失败,这时,仅比较值是不够的,需要再加一个版本号

此时需要用到 AtomicStampedReference

2,AtomicStampedReference

示例:

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();
}

AtomicStampedReference 可以给原子引用加上版本号,追踪原子引用整个的变化过程,如: A -> B -> A ->C ,通过AtomicStampedReference,我们可以知道,引用变量中途被更改了几次。

但是有时候,并不关心引用变量更改了几次,只是单纯的关心是否更改过,所以就有了AtomicMarkableReference

3,AtomicMarkableReference

换垃圾袋示例:垃圾袋为空的时候,主人才去换垃圾袋。

class GarbageBag {
	String desc;
	
	public GarbageBag(String desc) {
		this.desc = desc;
	}
	
	public void setDesc(String desc) {
		this.desc = desc;
	}
	
	@Override
	public String toString() {
		return super.toString() + " " + desc;
	}
}

@Slf4j
public class TestABAAtomicMarkableReference {
	public static void main(String[] args) throws InterruptedException {
		GarbageBag bag = new GarbageBag("装满了垃圾");
		// 参数2 mark 可以看作一个标记,表示垃圾袋满了
		AtomicMarkableReference<GarbageBag> ref = new AtomicMarkableReference<>(bag, true);
		
		log.debug("主线程 start...");
		GarbageBag prev = ref.getReference();
		log.debug(prev.toString());
		
		new Thread(() -> {
			log.debug("打扫卫生的线程 start...");
			bag.setDesc("空垃圾袋");
			while (!ref.compareAndSet(bag, bag, true, false)) {}
			log.debug(bag.toString());
		}).start();
		
		Thread.sleep(1000);
		log.debug("主线程想换一只新垃圾袋?");
		boolean success = ref.compareAndSet(prev, new GarbageBag("空垃圾袋"), true, false);
		log.debug("换了么?" + success);
		
		log.debug(ref.getReference().toString());
	}
}

3,原子数组

  • AtomicIntegerArray
  • AtomicLongArray
  • AtomicReferenceArray

4,字段更新器

  • AtomicReferenceFieldUpdater // 域 字段
  • AtomicIntegerFieldUpdater
  • AtomicLongFieldUpdater

5,原子累加器

6,Unsafe

11,共享模型之不可变类

不可变对象,实际是另一种避免竞争的方式。

1,不可变类

由于 SimpleDateFormat (是可变的)不是线程安全的,因此下面的多线程操作会出现问题:

SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
	new Thread(() -> {
		try {
		    // sdf.parse 会出现问题,因为其不是线程安全的
			log.debug("{}", sdf.parse("1951-04-21"));
		} catch (Exception e) {
			log.error("{}", e);
		}
	}).start();
}

同步锁可以解决问题,但带来的是性能上的损失,并不算很好:

SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 50; i++) {
	new Thread(() -> {
		synchronized (sdf) { // 同步处理
			try {
				log.debug("{}", sdf.parse("1951-04-21"));
			} catch (Exception e) {
				log.error("{}", e);
			}
		}
	}).start();
}

因为 SimpleDateFormat 是可变的,我们还可以使用不可变类 DateTimeFormatter 来替代 SimpleDateFormat。

如果一个对象不能够修改其内部状态(属性),那么它就是线程安全的,因为不存在并发修改。

这样的对象在Java 中有很多,例如在 Java 8 后,提供了一个新的日期格式化类 DateTimeFormatter:

DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
	new Thread(() -> {
		LocalDate date = dtf.parse("2018-10-01", LocalDate::from);
		log.debug("{}", date);
	}).start();
}

2,不可变类的设计 final

final 的使用:

  • 类和类中所有属性都是 final 的
  • 类用 final 修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性

保护性拷贝

  • 通过创建副本对象来避免共享的手段称之为【保护性拷贝】

3,不可变类与享元模式

不可变类通常会使用【保护性拷贝】来避免对象可变,但缺点是会频繁创建对象对象,并且对象个数较多。

因此不可变类一般会与享元模式一起使用。

享元模式:可以重用数量有限的同一类对象,目的是重用现有的对象,而不是创建新的对象。

JDK 中的享元模式:

  • 在JDK中 Boolean,Byte,Short,Integer,Long,Character 等包装类提供了 valueOf 方法,例如 Long 的 valueOf 会缓存 -128~127 之间的 Long 对象,在这个范围之间会重用对象,大于这个范围,才会新建 Long 对象。
public static Long valueOf(long l) {
	final int offset = 128;
	if (l >= -128 && l <= 127) { // will cache
		return LongCache.cache[(int)l + offset];
	}
	return new Long(l);
}

注意:

  • Byte, Short, Long 缓存的范围都是 -128~127
  • Character 缓存的范围是 0~127
  • Integer的默认范围是 -128~127
    • 最小值不能变
    • 但最大值可以通过调整虚拟机参数 -Djava.lang.Integer.IntegerCache.high 来改变
  • Boolean 缓存了 TRUE 和 FALSE

除了上面的包装类,String串池、BigDecimal 和 BigInteger 也都使用了享元模式。

4,无状态

在 web 阶段学习时,设计 Servlet 时为了保证其线程安全,都会有这样的建议,不要为 Servlet 设置成员变量,这种没有任何成员变量的类是线程安全的。

因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】。