Java并发 - 理论&线程基础

在并发环境下,经常会发生线程不安全等问题。

这些问题出现的根本原因要归结于并发的三要素没有全部得到满足:可见性、原子性、有序性。

可见性:CPU缓存引起

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

1
2
3
4
5
6
// Thread A
int i = 0;
i = 10;

// Thread B
j = i;

假设执行线程A的是CPU1,执行线程B的是CPU2。当线程A执行i = 10时,会先把i的初始值加载到CPU1的缓存中,然后赋值为10;此时CPU1的缓存中i的值就为10,但没有立即写入到主存中。

此时线程B执行j = i,会先去主存读取i的值然后加载到CPU2的缓存中,由于主存中i的值仍为0,那么j的值也会变成0,而不是10。

线程B没有能够立即看到线程A对于变量i的修改。

原子性:时分复用引起

原子性指的是多个操作要么全部执行成功要么全都不执行。例如:

1
2
3
4
5
6
7
int i = 1;

// Thread A
i += 1;

// Thread B
i += 1;

这里面i += 1这个语句需要执行三条CPU指令:

  • 首先将变量i从主存读取到CPU寄存器
  • 然后在CPU寄存器中执行i+1操作
  • 最后将结果写入主存/缓存

由于CPU时分复用的机制,线程A执行了第一条指令后,CPU就切换到线程B,假设线程B执行了这三条指令之后,CPU才切换回线程A执行后续的两条指令,那么写到主存中的i值就是2而不是3了。

有序性:重排序引起

有序性指的是程序执行的顺序以代码的先后顺序为依据。例如:

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

从代码顺序上看,语句1在语句2之前。但JVM在真正执行这段代码时可能会发生指令重排序。

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

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重排语句的执行顺序
  • 指令级并行的重排序:若不存在数据依赖性,处理器可以改变语句对应的机器指令的执行顺序
  • 内存系统的重排序:由于处理器使用缓存和读写缓冲区,导致加载和存储操作像是乱序执行

关于重排序的详解,可以看这篇文章Java内存模型中的相关内容。


Java如何解决并发问题——JMM

Java内存模型这篇文章详细写了JMM的具体内容。

JMM的本质实际上是规范了JVM如何提供按需禁用缓存和编译优化的方法,包括:

  • volatile/synchronized/final关键字
  • Happens-Before原则

相应地,针对前文所述的并发三元素,JMM是这样做到保障的:

  • 可见性

    JMM通过Happens-Before原则来保证可见性。使用volatile关键字也可确保可见性。当volatile修饰的共享变量被修改时,其新值会立即更新到主存。另外,synchronized和Lock也能够确保可见性。

  • 原子性

    JMM规定只有简单的读取、赋值(数字赋给变量,变量之间的赋值不算)才是原子操作。若要实现更大粒度的原子性,可通过synchronized和Lock来实现。

  • 有序性

    JMM通过Happens-Before原则来保证有序性。另外,使用volatile/synchronized/Lock都可以确保有序性。

关于volatile/synchronized关键字的详细内容,可以看这篇文章Java关键字

Happens-Before原则

  • 程序次序规则:在一个线程内,按照控制流顺序,若操作A先行发生于操作B,那么操作A所产生的影响对于操作B是可见的。
  • 管程锁定规则:对于同一个锁,若一个unlock操作先行发生于lock操作,则该unlock所产生的影响对于lock是可见的。
  • volatile变量规则:对于同一个volatile变量,若对于该变量的写操作先行发生于读操作,则写操作产生的影响对于读操作是可见的。
  • 线程启动规则:对于同一个Thread,调用该Thread的start方法所产生的影响对于该Thread的每一个动作都是可见的。
  • 线程终止规则:对于同一个Thread,其所有操作产生的影响对于调用该Thread的join方法和isAlive方法都是可见的。
  • 线程中断规则:对于同一个Thread,调用该Thread的interrupt方法产生的影响对于该Thread检测到中断事件是可见的。
  • 对象终结规则:对于同一个Thread,其构造方法结束所产生的影响对于其finalize方法的开始执行是可见的。
  • 传递性规则:若操作A先行发生于操作B,操作B先行发生于操作C,则操作A所产生的影响对于操作C是可见的。

线程使用方式

实现Runnable接口

实现自定义的run方法。通过Thread调用start方法来启动线程。

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
MyThread instance = new MyThread();
Thread thread = new Thread(instance);
thread.start();
}

private static class MyThread implements Runnable {
@Override
public void run() {
//TODO
}
}

实现Callable接口

相较于Runnable方法,多了返回值,返回值可以通过FutureTask封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyThread myThread = new MyThread();
FutureTask<Integer> futureTask = new FutureTask<>(myThread);
Thread thread = new Thread(futureTask);
thread.start();
System.out.println(futureTask.get());
}

private static class MyThread implements Callable<Integer> {
@Override
public Integer call() throws Exception {
return 3;
}
}

继承Thread类

当调用start方法启动一个线程时,虚拟机会将该线程放入就绪队列中,直到其被调度才会执行run方法。

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
}

private static class MyThread extends Thread {
@Override
public void run(){
//TODO
}
}

实现接口会更好一些,因为:

  • Java不支持多重继承,因此继承了Thread类就无法继承其它类,但是Java可以实现多个接口;
  • 类可能只要求可执行就行,继承整个Thread类开销过大。

基础线程机制

Executor

Executor管理多个异步任务的执行,无需用户显式地管理线程的生命周期。主要有三种Executor:

  • CachedThreadPool:一个任务创建一个线程
  • FixedThreadPool:所有任务只能使用固定大小的线程
  • SingleThreadExecutor:相当于大小为1的FixedThreadPool
1
2
3
4
5
6
7
8
public static void main(String[] args) {
MyThread myThread = new MyThread();
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i=0; i<10; i++){
executorService.execute(new MyThread());
}
executorService.shutdown();
}

Daemon

守护线程是程序运行时在后台提供服务的线程,并不一定需要。

当所有非守护线程结束时,程序就会终止,所有的守护线程也就被杀死。main()不属于守护线程。

1
2
Thread thread = new Thread(new MyThread());
thread.setDaemon(true);

sleep()

休眠当前线程若干毫秒。

yield()

调用yield()方法表示当前线程愿意放弃CPU,让给其他线程来使用。但并不一定保证会让出资源。


线程协作

join()

在线程中调用另一个线程的join()方法,会将当前线程挂起,直到目标线程结束。

wait()/notify()/notifyAll()

调用wait()使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用notify()或者notifyAll()来唤醒挂起的线程。

使用wait()挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行notify()或者notifyAll()来唤醒挂起的线程,造成死锁。

wait()sleep()的区别:前者是Object类的方法,后者是Thread类的方法;前者会释放锁,后者不会。

await()/signal()/signalAll()

调用await()方法使线程等待,其它线程调用signal()signalAll()方法唤醒等待的线程。相比于wait()这种等待方式,await()可以指定等待的条件,因此更加灵活。


线程安全的实现方法

线程安全指的是多个线程同时访问共享资源时,不会出现不准确的结果或者不稳定的行为。

具体的实现方法可分为三种:

互斥同步/阻塞同步

依靠关键字synchronizedReentrantLock。具体内容可以看这两篇文章:Java关键字JUC锁

非阻塞同步

由于互斥同步是一种悲观的并发策略,无论是否存在数据竞争,都会加锁。实际上并不是所有操作都需要加锁的,这导致了额外的线程阻塞,产生了性能上的缺陷。

可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程竞争共享数据,那操作就成功了,否则采取补偿措施(不断重试,直到操作成功),即非阻塞同步。

主要是通过CAS的思想来实现,具体内容可以看这篇文章:Java原子类

无同步

要保证线程安全,并不一定就要实现同步。如果一个方法本来就不涉及共享数据,则无需做任何同步。

依靠栈封闭线程本地存储可以实现。

  • 栈封闭:多个线程访问同一个方法的局部变量时不会有线程安全问题,因为局部变量存储于虚拟机栈中,是线程私有的。具体内容可以看这篇文章:JUC线程池

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

    线程本地存储主要通过ThreadLocal来实现,关于ThreadLocal的具体内容可以看这篇文章:ThreadLocal