查看原文
其他

应该了解的一些并发基础知识

一口仨馍 鸿洋 2019-04-05

每日推荐



版本检测升级(更新)库,链式编程,调用简单,集成轻松,扩展性强。

https://github.com/AlexLiuSheng/CheckVersionLib


本文作者


作者:一口仨馍

链接:

http://blog.csdn.net/qq_17250009/article/details/79012528

本文由作者投稿发布。


三个基础概念


  1. 原子性。一个操作或者一系列骚操作,要么全部执行要么全部不执行。数据库中的“事物”就是个典型的院子操作。

  2. 可见性。当一个线程修改了共享属性的值,其它线程能立刻看到共享属性值的更改。举个例子:由于JMM(Java Memory Model)分为主存和工作内存,共享属性的修改过程为从主存中读取并复制到工作内存中,在工作内存中修改完成之后,再刷新主存中的值。如果线程A在工作内存中修改完成但还没有刷新主存中的值,线程B看到的值还是旧值。这样可见性就没法保证。

  3. 有序性。程序的运行顺序似乎和我们编写逻辑的顺序是一致的,但计算机在实际执行中却并不一定。为了提高性能,编译器和处理器都会对代码进行重新排序。但是有个前提,重新排序的结果要和单线程执行程序顺序一致。


int a = 0;      // 语句A
int b = 1;      // 语句B
int c = a + b;  // 语句C


由于语句A和语句B没有数据依赖。重排序后,语句A和语句B,在计算机中的执行顺序可能是AB也可能是BA,但AB都与C有数据依赖,所以AB都在C前面执行。


Java中控制并发的几种方式


  1. volatile

  2. synchronized

  3. CAS/AQS

  4. concurrent并发包


1volatile


volatile用来保证可见性和有序性,不保证原子性。


volatile保证的可见性


volatile修饰的属性保证每次读取都能读到最新的值,可是并不会更新已经读了的值,它也无法更新已经读了的值。


线程A在工作内存中修改共享属性值会立即刷新到主存,线程B/C/D每次通过读写栅栏来达到类似于直接从主存中读取属性值,注意,是类似,网上有些说volatile修饰的变量读写直接在主存中操作,这种说法是不对的,只是表现出类似的行为。


读写栅栏是一条CPU指令,插入一个读写栅栏, 相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行(有序性)。读写栅栏另一个作用是强制更新一次不同CPU的缓存。例如,一个写栅栏会 把这个栅栏前写入的数据刷新到缓存,以此保证可见性。


volatile保证的有序性


当对volatile修饰的属性进行读/写操作时,其前面的代码必须已经执行完成且结果对后续的操作可见。在重排序时,以volatile修饰属性的读/写操作代码行为分界线,读/写操作前面的代码不许排序到后面,后面同理不许排序到前面。由此保证有序性.


{   // 线程A
   bean = new Bean();     // 语句A
   inited = true;         // 语句B
}
{   // 线程B
   if(inited){            // 语句C
       bean.getAge();     // 语句D
   }
}


在线程A中语句AB没有任何数据依赖,所以可能会被重排序成先执行语句B,后执行语句A。


假设线程A先执行完语句B之后(这时还没有执行语句A)被挂起,CPU转而执行线程B,由于bean对象没有初始化,所以在执行到语句D就会出错。如果inited属性用volatile修饰,就不会发生这种错误的重排序。


volatile不保证原子性


由于volatile保证可见性和有序性,被volatile修饰的共享属性一般并发读/写没有问题,可以看做是一种轻量级的synchronized的实现。但有些情况比较特殊,比如i++自增。举个栗子。


volatile int a = 0; // 语句A
a++;                // 语句B


a++。其实包含了两步操作。读取a, 执行a+1并将a+1结果赋值给a。假设线程A执行完第一步之后被挂起。线程B执行了a++。那么主存中a的值为1。


但是线程A的工作内存中还是0,由于线程A在之前就已经读取了a的值,执行a++之后再次将a的值刷新到主存,也就是说,a++执行了两次,但两次都是从0变为1。所以a的值最终为1。这里有个槽点,之前说volatile修饰的属性,每次读取都是最新的值,这里线程B执行a++之后,线程A里怎么还是0?


应该是1啊!我觉得这是volatile一个比较鸡肋的地方,volatile修饰的属性,如果在修改之前已经读取了值,那么修改之后,无法改变已经复制到工作内存的值。体会一下~


2synchronized


synchronized保证原子性、可见性和有序性。用来修饰方法或者代码块。下面是synchronized的一些规则。


  1. 根据锁对象的不同,一把锁同时最多只能被一个线程持有。

  2. 如果目标锁已经被当前线程持有,其它线程只能阻塞等待其它线程释放目标锁。

  3. 如果当前线程已经持有了目标锁,其他线程仍然可以调用目标类中没有被synchronized修饰的方法。


以上规则对下文的Lock同样适用。


锁对象举例


synchronized修饰方法或者synchronized(this)


public class Test {
   static SyncTest test1 = new SyncTest();
   static SyncTest test2 = new SyncTest();
   public static void main(String[] args) {
       new Thread(new Runnable() {
           @Override
           public void run()
{
               test1.syncTwo();
           }
       }).start();
       try {
           Thread.sleep(1000);
       } catch (InterruptedException e) {
           e.printStackTrace();
       }
       test1.syncOne();
   }
}
public class SyncTest {
   public synchronized void syncOne(){
       System.out.println("ThreadId : " + Thread.currentThread().getId());
       System.out.println("one");
   }
   public void syncTwo(){
       synchronized (this) {
           int a =0;
           while(true){
               a++;
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               System.out.println("ThreadId : " + Thread.currentThread().getId());
               if(a == 5) break;
           }
           System.out.println("two");
       }
   }
}


控制台输出:
ThreadId : 10
ThreadId : 10
ThreadId : 10
ThreadId : 10
ThreadId : 10
two
ThreadId : 1
one


为了先调用test1.syncOne(),这里先将主线程暂停1s。从控制台输出可以发现,子线程确实被阻塞了,从而说明synchronized修饰方法或者synchronized(this),获得的都是实例对象的锁。


如果将test1.syncOne()换成test2.syncOne();那么主线程就不会阻塞了。控制台输出为:


ThreadId : 1
one
ThreadId : 10
ThreadId : 10
ThreadId : 10
ThreadId : 10
ThreadId : 10
two


synchronized(xxx.class)/synchronized static


public class SyncTest {
   public synchronized static void syncOne() {
       System.out.println("ThreadId : " + Thread.currentThread().getId());
       System.out.println("one");
   }
   public void syncTwo() {
       synchronized (SyncTest.class) {
           int a = 0;
           while (true) {
               a++;
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               System.out.println("ThreadId : " + Thread.currentThread().getId());
               if (a == 5)
                   break;
           }
           System.out.println("two");
       }
   }
}


如果将SyncTest中的synchronized(this)换成synchronized(SyncTest.class)/synchronized static。那么无论调用test1.syncOne()还是test2.syncOne(),主线程都会阻塞。


synchronized(SyncTest.class)/synchronized static这种写法,保证对TestSync类的访问,同一时刻只能有一个线程持有锁。


synchronized实现的是阻塞型并发,synchronized修饰的范围越大,瓶颈越高。为了解决这种问题,由此又有减小锁范围、减小锁粒度和锁分段之说。鉴于篇幅,详细还请自行查看。


3CAS


CAS(compare and swap),即比较并交换。synchronized锁住的代码块,同一时刻只能由一个线程访问。属于悲观锁。相对于这种需要挂起线程的悲观锁,还一种由CAS实现的乐观锁。CAS包含三个部分:


  1. 内存地址A

  2. 预期旧值B

  3. 预期新值C


在进行CAS操作时,首先比较A和B,如果相等,则更新A中的值为C并返回true。否则,返回false。通常CAS伴随着死循环,以不断尝试更新的方式实现并发。伪代码如下:


public boolean compareAndSwap(long memoryA, int oldB, int newC){
   if(memoryA.get() == oldB){
       memoryA.set(newC);
       return true;
   }
   return false;
}


相对于synchronized省去了挂起线程、恢复线程的开销,但是如果迟迟得不到更新,死循环对CPU资源也是一种浪费。


使用CAS有个“先检查后执行”的操作,而这种操作在Java中是典型的不安全的操作,所以CAS在实际中是由C++通过调用CPU指令实现的。CAS在Java中的体现为Unsafe类,Unsafe类会通过C++直接获取到属性的内存地址,接下来CAS由C++的Atomic::cmpxchg方法实现。


这个方法会在CPU指令中添加lock指令,而带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存。具体可参考这篇文章。


https://www.jianshu.com/p/24ffe531e9ee


我觉得CAS相对于synchronize,本质上也是一种阻塞的实现。只是阻塞的粒度(CPU指令级别)更小。

4AQS


AQS(AbstractQueuedSynchronizer)中维护着一个volatile修饰的属性“state”和一个双向链表,通过使用Unsafe中CAS对“state”属性的一些列骚操作(实际就是把state当做标志位)实现独占锁和共享锁,独占锁和共享锁又分为公平锁和非公平锁。


  1. 独占锁:同一时刻只有一个线程持有同一锁,其余线程在链表中排队。

  2. 共享锁:同一时刻可以多个线程持有同一锁。

  3. 公平锁:锁被线程持有后,其余线程排队执行。锁按照FIFO放入链表。

  4. 非公平锁:锁被线程持有后,其余线程排队执行。锁按照FIFO放入链表。但是在刚释放锁的之后,如果有新线程竞争锁,那么新线程将和链表中下个即将被唤醒的线程竞争锁。


关于AQS,我找到两篇比较好的文章,这里就不赘述了。想深入了解的可以看下源码。


  • 深度解析Java 8:JDK1.8 AbstractQueuedSynchronizer的实现分析(上)

    http://www.infoq.com/cn/articles/jdk1.8-abstractqueuedsynchronizer

  • 深度解析Java 8:AbstractQueuedSynchronizer的实现分析(下)

    http://www.infoq.com/cn/articles/java8-abstractqueuedsynchronizer


5concurrent


JDK在java/util/concurrent提供了很多常用的并发类及并发容器类。并发类基本是通过lock(CAS/AQS)实现,并发容器基本是通过synchronize和lock(CAS/AQS)实现的。这是一篇基础,有机会再慢慢补齐这些。作者水平有限,如有错误,接受有偿指正~



最后推荐一下我做的网站,玩Androidwanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!



推荐阅读

这是一份全面 & 详细 的RxJava操作符 使用攻略

Android 混淆查缺补漏



如果你想要跟大家分享你的文章,欢迎投稿~

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存