多线程的安全问题以及对volatile关键字的理解

上一篇我们说到线程并发与并行在于我们看来都是多线程,多线程之间它们会共享当前进程的资源,在共享的过程中,会出现一系列的问题,如数据“脏读,死锁等问题。线程的原子性,有序性,可见性以及volatile关键字和synchronized。

Java内存模型

从上图我们知道Java内存模型规定了所有的变量都存储在主内存中。每条线程中有自己的工作内存,线程的工作内存中保存了被该线程所使用到的变量(这些变量是从主内存中拷贝而来)。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

在这里就出现了一些问题,线程A改变了变量i,那么线程B会及时的获取到吗?那么A改变了i,会及时的写入主内存中吗?要回答这些问题,我们首先要了解几个概念:原子性,有序性,可见性。

原子性
1.定义
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
2.例子
就说银行转账的例子,A给B转1000,那么分这几步,从账户A减去1000元,往账户B加上1000元。试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。
3.java的原子性
在Java编程过程中。怎么样的表达式才有原子性,那怎么样才能保证原子性呢。平常来说就一次操作的都有原子性,多次操作的我们可以用锁保证原子性(synchronized和Lock来实现)
如:

  1. x = 10; //语句1
  2. y = x; //语句2
  3. x++; //语句3
  4. x = x + 1; //语句4

语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
所以上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,从而保证了原子性。

在以上红色选中的三个部分,线程都有可能进行切换,而A线程进来了,突然到B进程,那么B进程改变了num=0;按道理来说已经没有票了,但是到A时,他已经进来了,就输出买票了。如果在一个线程进入到if中之后,当cpu切换到其他线程上时,不让其他的线程进入if语句,那么就算线程继续执行当前其他的线程,也无法进入到if中,这样就不会造成错误数据的出现。于是出现了用锁保证原子性(synchronized和Lock来实现)

  1. synchronized(任意的对象(锁) )
  2. {
  3. 写要被同步的代码
  4. }
  5. //书写售票的示例
  6. class Demo implements Runnable{
  7. //定义变量记录剩余的票数
  8. int num = 100;
  9. //创建一个对象,用作同步中的锁对象
  10. Object obj = new Object();
  11. //实现run方法
  12. public void run() {
  13. //实现售票的过程
  14. while( true ) {
  15. // t1 t2 t3
  16. //判断当前有没有线程正在if中操作num,如果有当前线程就在这里临时等待
  17. //这种思想称为线程的同步
  18. //当票数小等于0的时候,就不再售票了
  19. //使用同步代码块把线程要执行的任务代码可以同步起来
  20. synchronized( obj ) //t1 在进入同步之前线程要先获取锁
  21. /*
  22. 当某个线程执行到synchronized关键字之后,这时JVM会判断当前
  23. 同步上的这个对象有没有已经被其他线程获取走了,如果这时没有其他
  24. 线程获取这个对象,这时就会把当前同步上的这个对象交给当前正要进入
  25. 同步的这个线程。
  26. */
  27. {
  28. if( num > 0 )
  29. {
  30. //t0
  31. try{Thread.sleep(2);}catch( InterruptedException e ){}
  32. System.out.println(Thread.currentThread().getName()+"....."+num);
  33. num--;
  34. }
  35. }//线程执行完同步之后,那么这时当前这个线程就会把锁释放掉
  36. }
  37. }
  38. }
  39. class ThreadDemo {
  40. public static void main(String[] args) {
  41. //创建线程任务
  42. Demo d = new Demo();
  43. //创建线程对象
  44. Thread t = new Thread( d );
  45. Thread t2 = new Thread(d);
  46. Thread t3 = new Thread(d);
  47. Thread t4 = new Thread(d);
  48. //开启线程
  49. t.start();
  50. t2.start();
  51. t3.start();
  52. t4.start();
  53. }
  54. }

可见性
当然只一个原子性,也是不能保证数据的正确,还要有可见性。
1.定义
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
2.例子
还是上面的例子,如果synchronized这个只有原子性的话,那么保证了同一时刻只有一个线程执行,假如num=1,但是A线程改变了num=0,没有写入到主内存中,B线程获取到的数据还是1,也会有数据问题。所以通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。但是本身就是原子性的表达式,我们可以用volatile关键字修饰,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
如:

  1. //线程1
  2. boolean stop = false;
  3. while(!stop){
  4. doSomething();
  5. }
  6. //线程2
  7. stop = true;

很多人在中断线程时可能都会采用这种标记办法,但是这样会出现问题的,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。
那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。但是用volatile修饰之后就变得不一样了:
第一:使用volatile关键字会强制将修改的值立即写入主存;
第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。
那么线程1读取到的就是最新的正确的值。

注意:volatile不能保证原子性,但是又可见性和有序性。

volatile的应用场景
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中。
例子:
1.状态标记量

  1. volatile boolean flag = false;
  2. //线程1
  3. while(!flag){
  4. doSomething();
  5. }
  6. //线程2
  7. public void setFlag() {
  8. flag = true;
  9. }
  10. 有序性
  11. //x、y为非volatile变量
  12. //flag为volatile变量
  13. x = 2; //语句1
  14. y = 0; //语句2
  15. flag = true; //语句3
  16. x = 4; //语句4
  17. y = -1; //语句

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

  1. //线程1:
  2. context = loadContext(); //语句1
  3. inited = true; //语句2
  4. //线程2:
  5. while(!inited ){
  6. sleep()
  7. }
  8. doSomethingwithconfig(context);

可能语句2会在语句1之前执行,那么就有可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。
这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕