并行编程中三个基础概念(原子性,可见性,有序性)的理解与实践
在分析线程安全问题时,需要理解在并行编程中的三个基础概念,即原子性(Atomicity),可见性(Visibility)以及有序性(Ordering)。
原子性
原子性简介
即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
原子性的概念与数据库中的原子性概念一致,最适合用银行转账的例子来描述。
例如从账户A转出100元到账户B,可以分解为以下操作:
- 读取账户A的余额,300
- 计算账户A余额减去100后的余额,得到200
- 更新账户A的余额为200
- 读取账户B的余额,500
- 计算账户B的余额加上100后的余额,得到600
- 更新账户B的余额为600
以上操作要么全部成功,要么全部失败,不能出现执行到某一个步骤然后停止的情况,例如执行完第3步后忽然停止,那么账户A已经减少了100元,而账户B却没有任何增加。
原子性问题示例
在Java中典型的原子性问题就是变量的自加自减操作,例如count++
操作看起来只是一个操作,但其实包含了三个独立的操作:读取count的值,将值加1,然后将计算结果写入count。
例如我们编写一个计数器,用来记录当日登录系统的用户数量,如程序清单所示:
public class NonAtomicityDemo {
public static void main(String[] args) {
int i = 10;
while (i-- > 0) {
new Thread(() -> UnSafeCounter.addCount()).start();
}
}
}
class UnSafeCounter {
private static int counter = 0;
public static void addCount() {
counter++;
System.out.println(counter);
}
}
/**
* 输出
* 4
* 10
* 8
* 9
* 8
* 6
* 7
* 4
* 4
* 5
*/
我们可以观察到,开启10个线程调用UnSafeCounter的addCount()方法,输出的计数是错误的,这是因为count++操作并不是原子操作的原因,不同的线程可能获取count值时,count已经被另一个线程进行了赋值,而当前线程输出的仍然是未被改变的值。这种多个线程多次调用中返回错误的值将导致严重的数据完整性问题。在并发编程中,这种由于不恰当的执行时序而出现的不正确结果情况有一个正式的名字:竟态条件(Race Condition)。
如何解决原子性问题
在程序有数据完整性问题的时候,一般如何解决呢?其实只要保证操作是符合原子性的,那么就可以避免数据不一致的情况。
假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。
一般我们可以通过以下几种方法来保证操作的原子性。
-
通过synchronized关键字来保证同步
public class AtomicityBySynchronizedDemo { public static void main(String[] args) { int i = 10; while (i-- > 0) { new Thread(() -> UnSafeCounter.addCount()).start(); } } } class SafeCounter { private static int counter = 0; //synchronized关键字修饰 public static synchronized void addCount() { counter++; System.out.println(counter); } } /** * 输出,有序 * 1 * 2 * 3 * 4 * 5 * 6 * 7 * 8 * 9 * 10 */
通过对addCount()方法用synchronized关键字修饰来实现加锁,这样线程在争夺执行权时必须要先获得锁,当线程获得锁后其他的线程都只能等待锁的释放,这样就保证了addCount操作的原子性。
例如:- T1时刻,线程A获得SafeCounter.class的锁,开始执行addCount方法,这时,其他的线程都进入等待状态,无法执行addCount()方法
- T2时刻,线程A执行addCount()方法结束,释放SafeCounter.class的锁,其他的线程争夺锁
- T3时刻,线程B获得锁,并执行addCount()方法,其他线程等待,无法执行addCount()方法
- T4时刻,线程B执行addCount()方法结束,释放SafeCounter.class的锁,其他的线程争夺锁
- ....往复上述过程,直到程序正常退出
-
通过原子类来保证数据同步
concurrent包下提供了一些原子类,如:AtomicInteger、AtomicLong、AtomicReference等这些类提供了原子操作。例如:
public class AtomicityByAtomicIntegerDemo { public static void main(String[] args) throws InterruptedException { int i = 10; while (i-- > 0) { new Thread(() -> SafeCounter.addCount()).start(); } Thread.sleep(3000);//等待子线程执行结束 System.out.println(SafeCounter.counter); //输出10 } static class SafeCounter { private static AtomicInteger counter = new AtomicInteger(0); public static void addCount() { counter.incrementAndGet();//对比count++,counter.incrementAndGet()是原子操作,而count++不是原子操作 } } }
Lock显式锁来进行同步
//TODO
竟态条件(Race Condition)
当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。例如计数器Demo中的addCount()方法,addCount的正确性取决于对count进行+1操作和输出count的值必须是顺序执行的,但是不同的线程同时执行addCount()方法时,线程争夺执行权的过程中产生竟态条件。例如:
- 在T1时刻线程A读取了count的值=4,然后交出了执行权
- 在T2时刻线程B获得了执行权也读取了count的值=4,线程B对count进行+1操作,然后输出count的值为5并交出执行权
- 在T3时刻,线程A继续执行将count(值为4)进行+1操作,输出count的值为5并交出执行权
- 这就是多个线程交替执行,由于执行的时序,而产生的竟态条件问题。
那么如何解决竟态条件问题呢?
我们称导致竞态条件发生的代码区称作临界区。在临界区中使用适当的同步就可以避免竞态条件。在临界区一般通过使用synchronized或者Lock显式锁来对代码进行同步,这样来解决竟态条件问题。
可见性(Visibility)
线程可见性简介
线程之间的可见性是指当一个线程修改一个变量,另外一个线程可以马上得到这个修改值。
假设我们有2个线程:A为读线程,读取一个共享变量的值,并根据读取到的值来判断下一步执行逻辑;B为写线程,对一个共享变量进行写入。很有可能B线程写入的值对于A线程是不可见的。
两个线程间的不可见性
我们用一个例子来表示这种线程间变量不可见的情况。Nonvisibility中的示例包含两个共享数据的线程。写线程将更新标志,读线程将等待直到设置标志:
package com.random.jcp.base.thread.nature.visibility;
public class NonVisibilityDemo {
public static void main(String[] args) throws InterruptedException {
new ReadThread().start();
Thread.sleep(1000);
new WriteThread().start();
}
}
class ReadThread extends Thread {
@Override
public void run() {
System.out.println("read-thread start");
while (true) {
if (ShareData.flag == 1) {
System.out.println("read-thread end");
break;
}
}
}
}
class ShareData {
public static int flag = -1;
}
class WriteThread extends Thread {
@Override
public void run() {
ShareData.flag = 1;
System.out.println("write-thread:flag=" + ShareData.flag);
}
}
这个程序可能会一直循环下去,因为读线程可能读取不到写线程对于flag的写入而永远等待。
如何解决线程间不可见性
为了保证线程间可见性我们一般有3种选择:
- volatile:只保证可见性
- Atomic相关类:保证可见性和原子性
- Lock: 保证可见性和原子性
使用volatile
关键字来解决可见性问题
我们尝试更改上一个示例,使用volatile
关键字来修饰共享数据ShareData.flag
public class VisibilityByVolatileDemo {
public static void main(String[] args) throws InterruptedException {
new ReadThread().start();
Thread.sleep(1000);
new WriteThread().start();
}
static class ShareData {
//使用volatile关键字修饰
public static volatile int flag = -1;
}
static class ReadThread extends Thread {
@Override
public void run() {
System.out.println("read-thread start");
while (true) {
if (ShareData.flag == 1) {
System.out.println("read-thread end");
break;
}
}
}
}
static class WriteThread extends Thread {
@Override
public void run() {
ShareData.flag = 1;
System.out.println("write-thread:flag=" + ShareData.flag);
}
}
}
由于对ShareData.flag使用了volatile
关键字进行了修饰,程序可以正常结束,并且读线程可以正常的访问到写线程对共享数据flag的修改从而正常结束。
使用AtomicInteger
类来解决可见性问题
我们再尝试更改上一个示例,使用AtomicInteger
类来包装共享数据ShareData.flag:
import java.util.concurrent.atomic.AtomicInteger;
public class VisibilityByAtomicDemo {
public static void main(String[] args) throws InterruptedException {
System.out.println("\nVisibilityByAtomicDemo");
new ReadThread().start();
Thread.sleep(1000);
new WriteThread().start();
}
static class ShareData {
public static AtomicInteger flag = new AtomicInteger(-1);
private static volatile AtomicBoolean ready = new AtomicBoolean(false);
}
static class ReadThread extends Thread {
@Override
public void run() {
System.out.println("read-thread start");
while (true) {
if (ShareData.flag.get() == 1) {
System.out.println("read-thread end");
break;
}
}
}
}
static class WriteThread extends Thread {
@Override
public void run() {
ShareData.flag.set(1);
System.out.println("write-thread:flag=" + ShareData.flag);
}
}
}
由于ShareData.flag使用的类型是AtomicInteger,写线程对flag的修改对于读线程是可见的,这样写线程可以读取到flag被更新为1并正常退出。
使用synchronized
来解决可见性问题
使用synchronized
关键字对操作加锁也可以保证线程间的可见性,并且保证操作的原子性。
我们再构造一个示例来说明synchronized
关键字所起的作用。首先我们还是需要2个线程,一个读线程,一个写线程,然后把读写操作封装到ShareData中,然后观察在没有synchronized
关键字修饰时程序
的运行情况。
public class NonVisibilityDemo2 {
public static void main(String[] args) throws InterruptedException {
Thread writeThread = new Thread(() -> {
System.out.println("start write");
ShareData.write(100);
System.out.println("end write");
});
Thread readThread = new Thread(() -> {
while (true) {
if (ShareData.read() == 100) {
System.out.println("read it");
break;
}
}
});
readThread.start();
Thread.sleep(1000);
writeThread.start();
}
static class ShareData {
public static int flag = 0;
public static int read() {
return flag;
}
public static void write(int value) {
flag = value;
}
}
}
/**
* 输出:
* start write
* end write
**/
可以观察到程序并不停止,读线程没有读取到写线程对ShareData.flag的写入操作。写线程对flag的写入对于读线程不可见。
我们更改这个示例,使用synchronize
关键字对ShareData的read()方法和write()方法进行加锁操作。这样保证ShareData.flag对于读写线程都是可见的。
public class VisibilityBySynchronizedDemo {
public static void main(String[] args) throws InterruptedException {
System.out.println("\nVisibilityBySynchronizedDemo\n");
Thread writeThread = new Thread(() -> {
System.out.println("start write");
ShareData.write(100);
System.out.println("end write");
});
Thread readThread = new Thread(() -> {
System.out.println("reading...");
while (true) {
if (ShareData.read() == 100) {
System.out.println("read it");
break;
}
}
});
readThread.start();
Thread.sleep(1000);
writeThread.start();
}
static class ShareData {
public static volatile int flag = 0;
public static synchronized int read() {
return flag;
}
public static synchronized void write(int value) {
flag = value;
}
}
}
/**
* 输出:
* VisibilityBySynchronizedDemo
*
* reading...
* start write
* end write
* read it
**/
由于read()方法和write()方法都使用了synchronized关键字修饰,保证了原子性和可见性,程序正常结束并且输出read it。
线程间的不可见性是怎样产生的
TODO
Thread.sleep()导致的线程可见的情况
TODO