一、原子性

原子性是指:一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在Java中当我们讨论一个操作具有原子性问题是一般就是指这个操作会被线程的随机调度打断。

下面就是一段会出现原子性问题的代码:

  1. public class AtomicProblem {
  2. private static Logger logger = LoggerFactory.getLogger(AtomicProblem.class);
  3. public static final int THREAD_COUNT = 10;
  4. public static void main(String[] args) throws Exception {
  5. BankAccount sharedAccount = new BankAccount("account-csx",0.00);
  6. ArrayList<Thread> threads = new ArrayList<>();
  7. for (int i = 0; i < THREAD_COUNT; i++) {
  8. Thread thread = new Thread(new Runnable() {
  9. @Override
  10. public void run() {
  11. for (int j = 0; j < 1000 ; j++) {
  12. sharedAccount.deposit(10.00);
  13. }
  14. }
  15. });
  16. thread.start();
  17. threads.add(thread);
  18. }
  19. for (Thread thread : threads) {
  20. thread.join();
  21. }
  22. logger.info("the balance is:{}",sharedAccount.getBalance());
  23. }
  24. public static class BankAccount {
  25. private String accountName;
  26. public double getBalance() {
  27. return balance;
  28. }
  29. private double balance;
  30. public BankAccount(String accountName, double balance){
  31. this.accountName = accountName;
  32. this.balance =balance;
  33. }
  34. public double deposit(double amount){
  35. balance = balance + amount;
  36. return balance;
  37. }
  38. public double withdraw(double amount){
  39. balance = balance - amount;
  40. return balance;
  41. }
  42. public String getAccountName() {
  43. return accountName;
  44. }
  45. public void setAccountName(String accountName) {
  46. this.accountName = accountName;
  47. }
  48. }
  49. }

上面的代码中开启了10个线程,每个线程会对共享的银行账户进行1000次存款操作,每次存款10块,所以理论上最后银行账户中的钱应该是10 1000 10 = 100000块。我执行了多次上面的代码,很多次最后的结果的确是100000,但是也有几次的结果并不是我们预期的。

因为下面的操作并不是原子操作,其中的balance是一个共享变量。在多线程环境下可能会被打断。

balance = balance + amount;

上面的赋值操作被分为多步执行完成,下面简单解析下两个线程对balance同时加10的过程(模拟存款过程,假设balance的初始值还是0)

线程1从共享内存中加载balance的初始值0到工作内存
线程1对工作内存中的值加10

//此时线程1的CPU时间耗尽,线程2获得执行机会

线程2从共享内存中加载balance的初始值到工作内存,此时balance的值还是0
线程2对工作内存中的值加10,此时线程2工作内存中的副本值是10
线程2将balance的副本值刷新回共享内存,此时共享内存中balance的值是10

//线程2CPU时间片耗尽,线程1又获得执行机会
线程1将工作内存中的副本值刷新回共享内存,但是此时副本的值还是10,所以最后共享内存中的值也是10

上面简单模拟了一个原子性问题导致程序最终结果出错的过程。

1.1、JMM对原子性问题的保证

1、自带原子性保证
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作(例外就是long和double的非原子性协定,大家只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。

a = true;  //原子性
a = 5;     //原子性
a = b;     //非原子性,分两步完成,第一步加载b的值,第二步将b赋值给a
a = b + 2; //非原子性,分三步完成
a ++;      //非原子性,分三步完成

2、synchronized
synchronized可以保证操作结果的原子性。synchronized保证原子性的原理也很简单,因为synchronized可以防止多个线程并发执行一段代码。还是用上面存款的场景做列子,我们只需要将存款的方法设置成synchronized的就能保证原子性了。

 public synchronized double getBalance() {
            return balance;
        }

 public synchronized double deposit(double amount){
     balance = balance + amount; //1
     return balance;
 }

加了synchronized后,当一个线程没执行完deposit这个方法前,其他线程是不能执行这段代码的。其实我们发现synchronized并不能将上面的代码1编程原子性操作,上面的代码1还是有可能被中断的,但是即使被中断了其他线程也不能访问共享变量balance,当之前被中断的线程继续执行时得到的结果还是正确的。
因此synchronized对原子性问题的保证是从最终结果上来保证的,也就是说它只保证最终的结果正确,中间操作的是否被打断没法保证。这个和CAS操作需要对比着看。
PS:对于上面的getBalance方法大家可能会有点疑惑:只读操作为什么还要加上synchronized关键字。其实这边加上synchronized关键字的目的是为了保证balance变量的可见性,进入synchronized代码块每次都会去从主内存中读取最新值。

3、Lock锁

public double deposit(double amount) {
    readWriteLock.writeLock().lock();
    try {
        balance = balance + amount;
        return balance;
    } finally {
        readWriteLock.writeLock().unlock();
    }
}

4、原子操作类型

public static class BankAccount {
    //省略其他代码
    private AtomicDouble balance;

    public double deposit(double amount) {
        return balance.addAndGet(amount);
    }
    //省略其他代码
}

JDK提供了很多原子操作类来保证操作的原子性。原子操作类的底层是使用CAS机制的,这个机制对原子性的保证和synchronized有本质的区别。CAS机制保证了整个赋值操作是原子的不能被打断的,而synchronized值能保证代码最后执行结果的正确性,也就是说synchronized能消除原子性问题对代码最后执行结果的影响。

1.2、原子性总结

在多线程编程环境下(无论是多核CPU还是单核CPU),对共享变量的访问存在原子性问题。这个问题可能会导致程序错误的执行结果。JMM主要提供了如下的方式来保证操作的原子,保证程序不受原子性问题的影响。

  • synchronized机制:保证程序最终正确性,是的程序不受原子性问题的影响;
  • Lock接口:和synchronized类似;
  • 原子操作类:底层使用CAS机制,能保证操作真正的原子性。

二、可见性

2.1、什么是可见性问题

public class VolatileDemo {

    boolean started = false;

    public void startSystem(){
        System.out.println(Thread.currentThread().getName()+" begin to start system, time:"+System.currentTimeMillis());
        started = true;
        System.out.println(Thread.currentThread().getName()+" success to start system, time:"+System.currentTimeMillis());
    }

    public void checkStartes(){
        if (started){
            System.out.println("system is running, time:"+System.currentTimeMillis());
        }else {
            System.out.println("system is not running, time:"+System.currentTimeMillis());
        }
    }

    public static void main(String[] args) {
        VolatileDemo demo = new VolatileDemo();
        Thread startThread = new Thread(new Runnable() {
            @Override
            public void run() {
                demo.startSystem();
            }
        });
        startThread.setName("start-Thread");

        Thread checkThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    demo.checkStartes();
                }
            }
        });
        checkThread.setName("check-Thread");
        startThread.start();
        checkThread.start();
    }

}

上面的列子中,一个线程来改变started的状态,另外一个线程不停地来检测started的状态,如果是true就输出系统启动,如果是false就输出系统未启动。那么当start-Thread线程将状态改成true后,check-Thread线程在执行时是否能立即“看到”这个变化呢?答案是不一定能立即看到。这边我做了很多测试,大多数情况下是能“感知”到started这个变量的变化的。但是偶尔会存在感知不到的情况。请看下下面日志记录:

start-Thread begin to start system, time:1577079553515
start-Thread success to start system, time:1577079553516  
system is not running, time:1577079553516   ==>此处start-Thread线程已经将状态设置成true,但是check-Thread线程还是没检测到
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519

上面的现象可能会让人比较困惑,为什么有时候check-Thread线程能感知到状态的变化,有时候又感知不到变化呢?这个现象就是在多核CPU多线程编程环境下会出现的可见性问题。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程在工作内存中保存的值是主内存中值的副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。等到线程对变量操作完毕之后会将变量的最新值刷新回到主内存。
但是何时刷新这个最新值又是随机的。所以就有可能一个线程已经将一个共享变量更新了,但是还没刷新回主内存,那么这时其他对这个变量进行读写的线程就看不到这个最新值。(还有一种可能就是虽然修改线程已经将最新值刷新到主内存中去了,但是读线程的工作内存中副本的缓存值还没过期,那么读线程还是会使用这个副本值,而不是主内存中的最新值)这个就是多CPU多线程编程环境下的可见性问题。也是上面代码会出现问题的原因。

2.2、JMM对可见性问题的保证

在多CPU多线程编程环境下,对共享变量的读写会出现可见性问题。但是幸好JMM提供了相应的技术手段来帮我们规避这些问题,可以让程序正确运行。JMM针对可见性问题,主要提供了如下手段:

  • volatile关键字
  • synchronized关键字
  • Lock锁
  • CAS操作(原子操作类)

1、volatile关键字

使用volatile关键字修饰一个变量可以保证变量的可见性。所以对于上面的代码,我们只需要简单的修改下代码就可以让程序正确运行了。

private volatile boolean started = false;

使用volatile修饰一个共享变量可以达到如下的效果:

  • 一旦线程对这个共享变量的副本做了修改,会立马刷新最新值到主内存中去;
  • 一旦线程对这个共享变量的副本做了修改,其他线程中对这个共享变量拷贝的副本值会失效,其他线程如果需要对这个共享变量进行读写,必须重新从主内存中加载。

那么volatile具体是怎么达到上面两个效果的呢?其实volatile底层使用的是内存屏障来保证可见性的。
内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。
语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
对内存屏障做下简单的总结:

  • 内存屏障是一个指令级别的同步点;
  • 内存屏障之前的写操作都必须立马刷新回主内存;
  • 内存屏障之后的读操作都必须从主内存中读取最新值;
  • 在有内存屏障的地方,会禁止指令重排序,即屏障下面的代码不能跟屏障上面的代码交换执行顺序,即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

2、synchronized关键字

3、Lock接口

4、CAS机制(Atomic类)