loading...
Java并发-理论基础
Published in:2022-01-31 | category: Java多线程
Words: 5.8k | Reading time: 22min | reading:

在学习这篇文章之前,我们可以先去考虑这些问题,带着这些问题去理解多线程和并发的解决方案。

  • 多线程的出现是要解决什么问题的?
  • 线程不安全是指什么?举例说明
  • 并发出现线程不安全的本质是什么?可见性,原子性和有序性
  • Java是怎么解决并发问题的?3个关键字,JMM和8个Happens-Before
  • 线程安全是不是非真即假?不是
  • 线程安全有哪些实现思路?
  • 如何理解并发和并行的区别?

为什么需要多线程?

我们都知道,CPU,内存,I/O设备的速度是有极大差异的,为了合理利用CPU的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU增加了缓存,以均衡与内存的速度差异; //导致可见性问题
  • 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异; //导致原子性问题
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。 // 导致有序性问题

线程不安全示例

如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。

以下代码演示了10000个线程同时对cnt执行自增操作,操作结束后它的值有可能小于10000.

ThreadUnsafeExample

1
2
3
4
5
6
7
8
9
10
11
12
public class ThreadUnsafeExample {

private int cnt = 0;

public void add() {
cnt++;
}

public int getCnt() {
return cnt;
}
}

ThreadUnsafeTest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ThreadUnsafeTest {
public static void main(String[] args) throws InterruptedException {
final int threadSize = 10000;
ThreadUnsafeExample example = new ThreadUnsafeExample();
// CountDownLatch是一个同步工具类,用来协调多个线程之间的同步
// CountDownLatch能够使一个线程在等待另外一个线程完成各自的工作后,再继续执行。计数器的初始值为线程的数量。每当一个线程完成自己任务后,计数器的值就会减一
// 当计数器的值为0时,表示所有线程都已经完成一些任务,然后再CountDownLatch上等待的线程就可以恢复执行接下来的任务
CountDownLatch countDownLatch = new CountDownLatch(threadSize);

// 创建可缓存的线程池
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < threadSize ; i++) {
executorService.execute( () -> {
example.add();
// 线程开启并运行完后,计数器减去1
countDownLatch.countDown();
});
}

// 唤醒主线程
countDownLatch.await();
// 当前线程池停止接收新的任务,原来的任务继续执行
executorService.shutdown();
System.out.println(example.getCnt());
}
}

执行结果:

1
9978 //结果总是小于10000

并发出现问题的根源:并发三要素

上述代码出现为什么不是10000?并发出现问题的根源是什么?

可见性:CPU缓存引起

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。

举一个简单的例子,看下面这段代码:

1
2
3
4
5
6
// 线程1执行的代码
int i = 0;
i = 10;

// 线程2执行的代码
j = i;

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行i = 10这句时,会先把i的初始值加载CPU1的高速缓存中,然后赋值为10,那么再CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中.

此时线程2执行j = i, 它会先去主存读取i的值并加载到CPU2的缓存中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.

这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值.

原子性: 分时复用引起

原子性: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行.

我们来看一个经典的转账问题: 比如从账户A向账户B转账1000元,那么必然包括两个操作: 从账户A减去1000元,往账户B加上1000元.

试想一下,如果这两个操作不具备原子性,会造成什么样的后果.假如从账户A减去1000元之后,操作突然终止.然后又从B取出了500元, 取出500元之后,再执行往账户B加上1000元的操作.这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元.

所以这两个操作必须要具备原子性才能保证不出现一些意外问题.

有序性: 重排序引起

有序性: 即程序执行顺序按照代码的先后顺序执行.

举个简单的例子,看下面这段代码:

1
2
3
4
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2

上面代码定义了int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作.从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定在语句2前面执行吗?不一定,为什么呢?这里可能发生指令重排序(Instruction Reorder).

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序.重排序分为三种类型:

  • 编译器优化的重排序.编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序.
  • 指令级并行的重排序.现在处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP) 来将多条指令重叠执行.如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序.
  • 内存系统的重排序.由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序中执行.

从Java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

上述的1属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止).对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers intel 称之为 memory fence) 指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止).

Java是怎么解决并发问题的:JMM(Java内存模型)

Java内存模型是个很复杂的规范.

理解的第一个维度:核心知识点

JMM本质上可以理解为,Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法.具体来说,这些方法包括:

  • volatile、synchronized和final三个关键字
  • Happens-Before规则

理解的第二个维度: 可见性,有序性,原子性

  • 原子性

    在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行.做个小测试,请分析下哪些操作是原子性操作:

    1
    2
    3
    4
    x = 10;        //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
    y = x; //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
    x++; //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
    x = x + 1; //语句4: 同语句3

    上面4个语句只有语句1的操作具备原子性.

    也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作.

    从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和lock来实现.由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题,从而保证了原子性.

  • 可见性

    Java提供了volatile关键字来保证可见性.

    当一个共享变量被volatile修饰时,它会保证修改的值立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值.

    而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程被读取时,此时内存中可能还是原来的旧值,因此无法保证可见性.

    另外,通过synchronized和Lock也能保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中.因此可以保证可见性.

  • 有序性

    在Java里面,可以通过volatile关键字来保证一定的”有序性”.另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性.当然JMM是通过Happens-Before规则来保证有序性的.

关键字volatile、synchronized和final

看我其他的几个文章里面有啦!!!

Happens-Before规则

上面提到了可以用volatile和synchronized来保证有序性.除此之外,JVM还规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成.

单一线程原则

Single Thread rule

在一个线程内,在程序前面的操作先行发生于后面的操作.

管程锁定规则

Monitor Lock Rule

一个unlock操作先行发生于后面对同一个锁的lock操作.

volatile变量原则

Volatile Variable Rule

对一个volatile变量的写操作先行发生于后面对这个变量的读操作.

线程启动规则

Thread Start Rule

Thread对象的start()方法调用先行发生于此线程的每一个动作.

线程加入规则

Thread Join Rule

Thread 对象的结束先行发生发生于join()方法返回.

线程中断规则

Thread Interruption Rule

对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过interrupted()方法检测到否有中断发生.

对象终结规则

Finalizer Rule

一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始

传递性

Transitivity

如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C.

线程安全: 不是一个非真即假的命题

一个类在可以被多个线程安全调用时就是线程安全的.

线程安全不是一个非真即假的命题,可以将共享数据按照安全程度的强弱顺序分成以下五类: 不可变、绝对线程安全、相对线程安全、线程兼容和线程对立.

1. 不可变

不可变(Immutable)的对象一定是线程安全的,不需要采取任何的线程安全保障措施.只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致地状态.

多线程环境下,应当尽量使对象成为不可变,来满足线程安全.

不可变类型:

  • final关键字修饰的基本数据类型

  • String

  • 枚举类型

  • Number部分子类,如Long 和Double 等数值包装类型,BigInteger和 BigDecimal等大数据类型.但同为Number的原子类AtomicInteger和AtomicLong则是可变的.

  • 对于集合类型,可以使用Conllection.unmodifiableXXX()方法来获取一个不可变的集合.

    Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常。

2. 绝对线程安全

不管运行时环境如何,调用者都不需要任何额外的同步措施.

3. 相对线程安全

相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施.但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性.

在Java语言中,大部分线程安全类都属于这种类型,例如Vector,HashTable,Collections的sychronizedConllection()方法包装的集合等.

对于下面的代码,如果删除元素的线程删除了Vector的一个元素,而获取元素的线程试图访问一个已经被删除的元素,那么就会抛出ArrayIndexOutOfBoundException.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class VectorUnsafeExample {
private static Vector<Integer> vector = new Vector<>();

public static void main(String args[]) {
while (true) {
for (int i = 0; i < 100; i++) {
vector.add(i);
}
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
});
executorService.execute( () -> {
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
}
});
executorService.shutdown();
}
}
}

1
2
3
4
5
6
Exception in thread "pool-420592-thread-1" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 144
at java.util.Vector.remove(Vector.java:831)
at example.VectorUnsafeExample.lambda$main$0(VectorUnsafeExample.java:24)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at java.lang.Thread.run(Thread.java:748)

如果要保证上面的代码能正确执行下去,就需要对删除元素和获取元素的代码进行同步.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
});
executorService.execute( () -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
System.out.println(i + " ==== " + vector.get(i));
}
}
});

4. 线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况.Java API中大部分类都是属于线程兼容的,如与前面的Vector 和 HashTable 相对应的集合类ArrayList 和 HashMap等.

5. 线程对立

线程对立是指无论调用端是否采取了同步措施,都无法多线程环境中并发使用的代码.由于Java语言天生就具备多线程,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免.

线程安全的实现方法

互斥同步

// TODO

非阻塞同步

互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步.

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题.无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作.

(一) CAS

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,知道成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换(Compare-and-Swap , CAS)。CAS指令需要有三个操作数,分别是内存地址V、旧的预期值A和新值B。当执行操作时,只有当V的值等于A,才将V的值更新为B。

(二) AtomicInteger

J.U.C包里面的整数原子类AtomicInteger,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作。

以下代码使用了AtomicInteger执行自增操作。

1
2
3
4
5
private AtomicInteger cnt = new AtomicInteger();

public void add() {
cnt.incrementAndGet();
}

以下代码是incrementAndGet()的源码,它调用了unsafe的getAndAddInt()。

1
2
3
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

以下代码是getAndAddInt() 源码,var1指示对象内存地址, var2指示该字段相对对象内存地址的偏移,var4指示操作需要加的数值,这里为1。这里通过getIntVolatile(var1, var2) 得到旧的预期值,通过调用compareAndSwapInt() 来进行CAS比较,如果该字段内存地址中的值等于var5,那么就更新内存地址为var1+var2的变量为var5+var4。

可以看到getAndAddInt()在一个循环中进行,发生冲突的做法是不断地进行重试。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* var1 对象内存地址
* var2 字段相对对象内存地址的偏移
* var4 操作需要加的数值
*/
public final int getAndSetInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2); // 旧的预期值
} while(!this.compareAndSwapInt(var1, var2, var5, var4));

return var5;
}

(三) ABA

如果一个变量初次读取的时候是A值,它的值被改成了B,后来又被改回了A,那CAS操作就会误认为它从来没有被改变过。

J.U.C包提供了一个带有标记的原子引用类AtomicStampedReference来解决这个问题,它可以通过控制变量值来保证CAS的正确性。大部分情况下ABA问题不会影响程序并发的正确,如果需要解决ABA问题,改用传统的互斥同步可能会比源自类更高效。

无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本身就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

(一)栈封闭

多个线程访问同一方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

1
2
3
4
5
6
7
8
9
10
11
12
13
package example;


public class StackClosedExample {
public void add() {
int cnt = 0 ;
for (int i = 0; i < 100 ; i++) {
cnt++;
System.out.println(Thread.currentThread().getName() + ": " + cnt);
}
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package test;

import example.StackClosedExample;


import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;


public class StackClosedTest {

public static void main(String[] args) {
StackClosedExample stackClosedExample = new StackClosedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> stackClosedExample.add());
executorService.execute(() -> stackClosedExample.add());
executorService.shutdown();
}
}

1
2
3
4
5
6
7
8
pool-1-thread-1: 98
pool-1-thread-1: 99
pool-1-thread-1: 100
pool-1-thread-2: 44
...
pool-1-thread-2: 98
pool-1-thread-2: 99
pool-1-thread-2: 100

(二) 线程本地存储(Thread Local Storage)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典Web交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多Web服务端应用都可以使用线程本地存储来解决线程安全问题。

可以使用java.lang.ThreadLocal类来实现线程本地存储的功能。

对于以下代码,thread1中设置threadLocal为1,而thread2设置threadLocal为2。过了一段时间后,thread1读取threadLocal依然是1,不受thread2的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ThreadLocalExample {
public static void main(String[] args) {
ThreadLocal<Object> threadLocal = new ThreadLocal<>();
Thread thread1 = new Thread(() -> {
threadLocal.set(1);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(threadLocal.get());
threadLocal.remove();
});

Thread thread2 = new Thread(() -> {
threadLocal.set(2);
threadLocal.remove();
});

thread1.start();
thread2.start();
}
}

输出结果:

1
1

为了理解ThreadLocal.先看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ThreadLocalExample1 {
public static void main(String[] args) {
ThreadLocal<Object> threadLocal1 = new ThreadLocal<>();
ThreadLocal<Object> threadLocal2 = new ThreadLocal<>();

Thread thread1 = new Thread(() -> {
threadLocal1.set(1);
threadLocal2.set(2);
});

Thread thread2 = new Thread(() -> {
threadLocal1.set(2);
threadLocal2.set(2);
});
thread1.start();
thread2.start();
}
}

它所对应的底层结构图为:

每个Thread都有一个ThreadLocal.ThreadLocalMap对象,Thread类中就定义了ThreadLocal.ThreadLocalMap成员。

1
ThreadLocal.ThreadLocalMap threadLocals = null;

当调用一个ThreadLocal的set(T Value)方法时,先得到当前线程的ThreadLocalMap对象,然后将ThreadLocal->value键值对插入到该Map中。

1
2
3
4
5
6
7
8
9
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}

get()方法类似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

ThreadLocal从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。

在一些场景下(尤其是在使用线程池)下,由于ThreadLocal.ThreadLocalMap的底层数据结构导致ThreadLocal有内存泄漏的情况,应该尽可能在每次使用ThreadLocal后手动调用remove(),以避免出现ThreadLocal经典的内存泄漏甚至是造成自身业务混乱的风险。

(三) 可重入代码(Reentrant Code)

这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断他,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公共的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。

Prev:
SpringBoo自动配置
Next:
BIO、NIO、AIO
catalog
catalog