多线程三部曲——线程间同步与通信

学习多线程,那线程间同步与通信是必不可少得内容啦!

首先我们讲一下什么是线程间同步,其实我们在上一章已经提到过一种线程间通信方式了:Handler作为线程间通信得桥梁,也是最常用得线程间通信方式。那线程间通信还有其他方式吗?答案是有滴。

一、线程间通信方式

(1)、Handler桥梁

在Android中Handler机制是系统核心机制的重要成员之一,可以说整个Android系统几乎都是运行在这些机制之上的。那么什么是Handler机制呢?这里我们先不做详细讲解,后面会单独做一篇Handler机制详解的文章。在这里我们只需要了解几个概念即可。

首先是Handler机制的组成:

Handler:负责将消息插入消息队列,以及处理消息。与Looper进行唯一绑定。

Looper:负责消息轮询,每个线程通过Handler发送的消息都保存在MessageQueue中,Looper通过调用loop()的方法,就会进入到一个无限循环当中,然后每当发现Message Queue中存在一条消息,就会将它取出,并传递到Handler的handleMessage()方法中。每个线程中只会有一个Looper对象

MessageQueue:消息队列,它主要用于存放所有通过Handler发送的消息(也就是一个个Message),这部分的消息会一直存在于消息队列中,等待被处理。每个线程中只会有一个MessageQueue对象。若消息队列中没有消息,进入阻塞状态。

Message:对每个消息事件的分装,内部taget变量会指向发送它的Handler对象,这样当轮询到该消息事件时就能够找到对应要处理该消息的Handler了。

Handler的创建和使用

   //在主线程中创建Handler,并绑定主线程的Looper对象
        final Handler mainHandler = new Handler(Looper.getMainLooper()){
            @Override
            public void handleMessage(Message msg) {
                super.handleMessage(msg);
                //TODO  接收消息并处理业务逻辑
            }
        };
        final Handler[] threadHandler = new Handler[1];
        Thread testThread = new Thread(new Runnable() {
            @Override
            public void run() {
                //创建当前线程的Looper对象,这里需要讲一下,如果当前线程没有执行这一步,也就是没有创建当前线程的Looper对象,
                // 那么下面创建Handler对象将会报异常:looper must not be null
                Looper.prepare();
                //创建Handler并绑定Looper
                threadHandler[0] = new Handler(Looper.myLooper()){
                    @Override
                    public void handleMessage(Message msg) {
                        super.handleMessage(msg);
                        //TODO 处理业务逻辑
                        Message message = new Message();
                        message.what=0;
                        message.obj = "test handler";
                        mainHandler.sendMessage(message);
                    }
                };
                //启动消息循环,不执行这一步将不会执行消息轮询
                Looper.loop();
            }
        });
        testThread.start();
        threadHandler[0].sendEmptyMessage(0);

 (2)runOnUiThread方法

通常在Android开发中如果我们在线程中想更新UI,但是又不想那么麻烦的去创建Handler对象时,就可使用runOnUiThread方法来实现。

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        new Thread(new Runnable() {
            @Override
            public void run() {
                //直接执行runOnUiThread方法在线程中更新UI,方法很简单这里就不细说了
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        //TODO  更新UI
                    }
                });
            }
        }).start();
    }
}

(3)View.post(Runnable r)方法

这种方法更加简单,但需要传递要更新的View过去,推荐使用。

    private void ViewThreadPost(){
        new Thread(new Runnable() {
            @Override
            public void run() {
                //其使用方法和runOnUiThread几乎一模一样。同样不细说了。有兴趣可以看一下这个两个方法的源码,
                // 其实到头来其内部还是用Handler实现的。这也就是我们为什么说其实整个Android系统都是建立在像Handler这样的机制之上的
                view.post(new Runnable() {
                    @Override
                    public void run() {
                        //TODO 更新UI
                    }
                });
            }
        }).start();

(4)AsyncTask

这个上一篇已经讲过了,这里也就不再废话了。

二、线程间同步

讲线程间同步之间我们需要先讲一下什么是内存模型,只有弄明白了什么是内存模型,我们才能够明白为什么需要线程间同步。

(1)内存模型

JVM在运行时会将我们的内存分配成若干的数据区,这些数据区又可分为线程私有和线程公有

线程私有的数据区:程序计数器、本地方法栈、虚拟机栈。

线程公有的数据区:堆、方法区。

看到这些名词可能有人会一脸懵逼,其实我也差不多啦,那么怎么办呢?学习哇。我们先来看一张图。然后我再一个数据区一个数据区的讲解

线程私有:

(1)、程序计数器:指向当前线程正在执行的字节码指令的地址。(一脸闷逼态,这是个什么玩意?别急往下看。)

java是多线程的,这就意味着要进行线程间的来回切换,同时还要确保在线程切换的过程中程序能够正常执行。这时就需要程序计数器来记录当前线程中程序的执行进度,而进度就是字节码的指令地址。

(2)java虚拟机栈:每个线程在创建时都会创建一个虚拟机栈,内部保存一个个的栈帧,这些栈帧对应着一次次的函数调用。在一个时间点,对应的只会有一个活动的栈帧,通常叫做当前帧,方法所在的类叫做当前类。如果在当前正在执行的方法内调用了其他方法,对应的将会创建一个新的栈帧,成为新的当前帧,一直到它返回结果或者执行结束。java对虚拟机栈只会执行两种操作:压栈和出栈。栈帧中存储着对应方法的局部变量表,操作数栈,动态链接和方法正常退出或者异常退出的定义。即虚拟机栈中存储着栈帧,而每个栈帧里面存储着当前方法所需的数据、指令和返回等信息。

java类中每一个方法对应一个栈帧。而栈帧又分为如下几种:

a、局部变量表:用于存放类对象的引用(this)、基本数据类型的局部变量、局部对象(方 法内创建的变量)的引用。

b、操作数栈:字节码文件中的出栈入栈等操作

c、动态链接:多态

c、返回地址:方法的返回return

(3)本地方法栈:它和虚拟机栈非常类似,也是每个线程都会创建一个。不过它存储的是本地方法(native方法)的数据、指令和返回。

注:线程私有区域的内存的生命周期是跟随线程的。

线程公有

(1)堆:存储对象、数组。垃圾回收器的主要工作区域。

(2)方法区:静态变量、常量、类信息(class信息)、即时编译期编译后的代码(动态语言)

java将线程公有的堆区域根据垃圾回收器进行了进一步的细分,分成了年轻代和老年代,将方法区根据jdk版本的不同分成了永久代或是元空间

 至于堆内存如何分配、如何回收,这里就不再细说了。后面会再写一篇这方面的文章。因为这一块涉及到的内容会很多。一时讲不完。这里我们知道了内存模型会将数据区分为线程私有和线程公有。也就是说线程公有区域的数据所有线程都能够直接访问。而线程私有的数据区只有当前线程能够直接访问(这也是为什么需要线程间通信的原因了)。那么问题来了。既然线程公有区域所有线程都能够访问的,那么这块区域如何做到数据同步呢?要知道开发中很多时候既要保证任务执行的效率,又要确保数据的同步和共享。

(2)线程同步 

a、同步锁synchronized

synchronized关键字可以修饰方法或者代码块,它主要确保多个线程在同一时刻,只能由一个线程处于方法或者代码块中。它保证了线程对变量访问的可见性和排他性,又称为内置锁机制。这里我们需要了解两个名词:对象锁和类锁。

对象锁:用于对象的实例方法、或者一个对象实例上。

类锁:用于类的静态方法或者一个类的Class对象上。我记得前面在说泛型的时候又提过,一个类的对象实列可以有很多个,但是每个类只有一个Class对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁,准确来说类锁只是概念上的东西,类锁实质上锁的是每个类的Class对象。且类锁和对象锁互不干扰。

    //当有线程获取到当前实例对象并调用该方法时,即获取到该对象锁
    public synchronized void ObjectLock(){
       
    }
    
    public void ClassSynchronized(){
        //类锁,也叫Class对象锁
        synchronized (MainActivity.class){
            
        }
    }
   //类锁,实际上锁的是类的Class对象
    public static synchronized void classLock(){
        System.out.println("Class Lock");
        doWork();//这里再去执行同样是类锁的的代码块时,发现这个类锁已将被当前线程获取了,就不需要再重新申请这个类锁了,这就叫可重入性。
    }

    public static synchronized void doWork(){
        System.out.println("Class doWork");
    }

b、显示锁Lock

synchronized关键字将会隐式的获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放,中间不可中断,没有一个尝试获取锁的机制。

而Lock是由Java在语言层面提供的,锁的获取和释放需要我们显示的去获取,因此被称为显示锁。并且提供了synchronized锁不具备的尝试获取锁的机制。Lock在JDK中是个接口,内部提供了很多方法,这些方法都需要开发者自己去实现。如下:

但是贴心的JDK还是帮我们实现了一些比较常用的显示锁的。如:可重入锁ReentrantLock和读写读写锁ReentrantReadWriteLock

ReentrantLock:可重入锁,顾名思义,当执行线程在获取了锁之后在没有释放锁的情况下,下次再进来就不需要再重新获取锁了,即ReentrantLock在调用lock方法时,已经获取到锁的线程能够再次调用lock()方法获取锁而不被阻塞。其实这里还有一个公平锁和非公平锁的概念:如果在时间上,先对锁进行获取的请求一定先得到满足,那么这个锁就是公平锁,也就是先来的先得。反之,就是非公平锁。synchronized缺省是非公平的。ReentrantLock提供了一个构造函数,能够控制锁是否公平,事实上。公平锁机制往往没有非公平锁的效率高。原因是在恢复一个被挂起的线程与该线程真正开始运行之间存在着一定的延时。比如假设线程A持有一个锁,并且线程B也请求了这个锁。但是由于这个锁已经被A持有,所以线程B将被挂起,当线程A释放锁时B将被唤醒,因此会再次尝试获取锁。于此同时,如果来了一个线程C也请求这个锁,如果这个锁的非公平锁,那么线程C很可能会在B被完全唤醒前获得、使用和释放了这个锁。这时候就实现了双赢,B获取锁的时刻没有被推迟,且线程C也完成了它该完成的工作。提高了吞吐量。但是如果这个锁如果是公平锁就不行了,线程想获得锁就必须等线程B释放锁之后了。

    ReentrantLock reentrantLock = new ReentrantLock();
    private void ReentrantLockTest(){
        reentrantLock.lock();
        try {
            //可能有人问,这里为什么要加一个Try/Catch语句,那是因为如果获取到锁后,下面这段逻辑出现异常了,
            // 如果不用try/catch就有可能会造成锁无法释放的情况。
            for(int i=0;i<10;i++){
                
            }
        }catch (Exception e){
            
        }finally {
            reentrantLock.unlock();
        }
        
    }

ReentrantReadWriteLock:前面提到的锁基本都是排他锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一个时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞,读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大的性能提升。除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定一个共享的用于缓存的数据结构,它大部分时间提供读服务,而写操作的时间很少,但写操作完成后的更新需要对后续的读服务可见。一般情况下,读写锁的性能都会比排他锁好,因为大多数场景读是多余写的,在读多余写的情况下,读写锁能够提供比排他锁更好的并发性和吞吐量,所以我们经常在为数据库增加锁时,读写锁可能会是一种比较好的选择。

c、volatile关键字

可能很多人和我一样,听说过volatile关键字,但是在实际开发中更常用的是上面说的synchronized,几乎不怎么使用volatile关键字,所以也就对volatile关键字的了解少之又少了。

在讲volatile关键字之前我们要先了解一下什么是原子操作可见性指令重排。这里借鉴一下真大神的文章

1.原子性

  原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

  一个很经典的例子就是银行账户转账问题:

  比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。

  试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。

  所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

  同样地反映到并发编程中会出现什么结果呢?

  举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?

i = 9;

   假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。这句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子i:

  请分析以下哪些操作是原子性操作:

x = 10;         //语句1

y = x;         //语句2

x++;           //语句3

x = x + 1;     //语句4

   咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。

  语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。

  语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。

  同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。

   所以上面4个语句只有语句1的操作具备原子性。

  也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。

  从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

2.可见性

  可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

  举个简单的例子,看下面这段代码:

//线程1执行的代码

int i = 0;

i = 10;



//线程2执行的代码

j = i;

   假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

  此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

  另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3.有序性

  有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

int i = 0;              

boolean flag = false;

i = 1;                //语句1  

flag = true;          //语句2

   上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。

  下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

  比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。

  但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

int a = 10;    //语句1

int r = 2;    //语句2

a = a + 3;    //语句3

r = a*a;     //语句4

   这段代码有4个语句,那么可能的一个执行顺序是:

  那么可不可能是这个执行顺序呢: 语句2 -> 语句1  ->  语句4  -> 语句3

  不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

  虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

//线程1:

context = loadContext();   //语句1

inited = true;             //语句2



//线程2:

while(!inited ){

  sleep()

}

doSomethingwithconfig(context);

   上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此时线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,但实际上context并没有被初始化,就会导致程序出错。

   从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

上面说了这么多原子性、可见性、有序性的概念,那么volatile关键字能否保证这些特性呢?

volatile关键字能否保证可见性:

    1)、使用volatile关键字会强制将修改的值立即写入主存(也就是线程公有数据区)

 2)、使用volatile关键字的话,当线程进行修改变量时,会导致其他线程的工作内存中缓存变量的缓存行无效。

 3)、由于其他线程的工作内存中缓存变量stop的缓存行无效,所以当这些线程再次读取变量的值时会去主存读取。

volatile关键字能否保证原子性:

答案是volatile关键字是无法保证对变量的任何操作都是原子性的。所以JDK为我们专门提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。咦?这里又多出来一个CAS操作是个什么东东?有兴趣的朋友可以取百度一下这是什么东西。难说的狠就不在这里讲了。

volatile关键字能够保证有序性:

volatile关键字能禁止指令重排序,这也是它最大的优点之一,所以volatile能在一定程度上保证有序性。

禁止指令重排序有两层意思:

  1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

  2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行

volatile的原理和实现机制

  上面讲了些volatile关键字的特性,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。

  下面这段话摘自《深入理解Java虚拟机》:“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”。lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  2)它会强制将对缓存的修改操作立即写入主存;

  3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

发布了29 篇原创文章 · 获赞 3 · 访问量 902

猜你喜欢

转载自blog.csdn.net/LVEfrist/article/details/92150030