专栏名称: CoorChice
Android、Java、Python、Kotlin、c++
今天看啥  ›  专栏  ›  CoorChice

【拒绝一问就懵】之有必要单独讲讲线程

CoorChice  · 掘金  · 程序员  · 2019-05-15 15:01
阅读 22

【拒绝一问就懵】之有必要单独讲讲线程

有什么料?

  1. 进一步理解多线程场景下会出现的问题;
  2. 学会正确处理并发操作中的通讯和同步。

现在,多了解些线程吧

在日常开发中,线程常常被用作为提升程序效率的重要手段。在CoorChice的这篇文章中,CoorChice介绍了线程的基本运作。链接:

【拒绝一问就懵】之从Thread讲到Handle

本篇,CoorChice将从多线程的角度来进一步介绍线程的相关知识。首先,我们需要了解一些基本知识。

主内存和工作内存

  • 主内存
    暂且可以理解为内存模型中堆内存。它储存了进程的所有共享变量。我们知道,一个进程中可能存在包括主线程在内的多条线程。++主内存中的共享变量是对所有线程可见的。++
  • 工作内存
    为了提高效率,每个线程都配有一个私有的工作内存。主内存中的共享变量需要拷贝到线程的私有内存中,之后线程对该变量的操作就是在自己的工作内存中进行的。++当值发生改变时,在线程退出之前,会被更新到主内存中。++

想了解更多和Java内存相关的知识,可以看看CoorChice的这几篇文章:
1. 【拒绝一问就懵】之你多少要懂点内存回收机制

2.【拒绝一问就懵】之没听说过内存抖动吧

3.【拒绝一问就懵】之不可忽视的内存泄露

共享变量和非共享变量

  • 共享变量

    如果一个变量在多条线程的工作内存中都有拷贝,那么就认定它是一个共享变量。++事实上,类的成员变量、静态变量都是共享变量。++ 如上所术,共享变量对进程中的所有线程都是可见的。我们经常遇到的并发问题通常就是由它引起的。

  • 非共享变量:

    就是线程中的私有变量。这些变量对其它线程来说是不可见。当线程退出时,它们会被回收的。非共享变量的值需要通过通讯手段才能传递到其它线程,这个后面再提。

其它

  • 原子操作:

    就是不可分割的,连续不断的操作。比如后面将要提到的Read操作。

  • 可见性:

    一个线程对共享变量值的修改,能够被其它线程即时看到,就称该共享变量具有可见性。

由共享变量引发的问题

现在,筒靴们已经知道了共享变量对进程中的所有线程都是可见的。并且当一个线程需要使用它时,需要先拷贝一份到自己的工作内存中,然后再工作内存中操作这个copy的对象。下面这张图展示线程中操作共享变量的过程。

image

图中展示了线程对共享变量的读取/写入操作。可以看到,++它们分别由两个原子操作构成。++注意,CoorChice这句话的意思是,通常意义上的读取一个变量或者写入一个变量的操作都不是原子操作,而是分两步完成的。

读取

  1. read:
    将主内存中的变量值读取到线程的工作内存中。
  2. load:
    将read到的值赋给新建的拷贝变量。 ### 写入
  3. store:
    将线程的工作内存中的,共享变量的拷贝变量的值传到主内存中。
  4. write:
    将store后的值赋给主内存中共享变量。

你看,不论是读取还是写入,由于都需要两步完成,所以就很可能发生中途被中断的情况。比如下面这段代码每次执行的结果都有可能不一样。

int goods = 0;

@Test
public void testThread() {
    for (int i = 0; i < 3; i++) {
      new Thread(() -> {
        while (goods != 10) {
          goods++;
          System.out.println(
          Thread.currentThread().getName() + 
            " -> Goods = " + goods);
        }
      }, "Thread - " + i).start();
    }
  }
复制代码

第一次运行结果:

Thread - 0 -> Goods = 1
Thread - 0 -> Goods = 3
Thread - 0 -> Goods = 4
Thread - 1 -> Goods = 2
Thread - 2 -> Goods = 6
Thread - 2 -> Goods = 8
Thread - 2 -> Goods = 9
Thread - 2 -> Goods = 10
Thread - 0 -> Goods = 5
Thread - 1 -> Goods = 7
复制代码

第二次运行结果

Thread - 0 -> Goods = 1
Thread - 1 -> Goods = 2
Thread - 0 -> Goods = 3
Thread - 0 -> Goods = 5
Thread - 0 -> Goods = 6
Thread - 2 -> Goods = 7
Thread - 2 -> Goods = 9
Thread - 1 -> Goods = 4
Thread - 2 -> Goods = 10
Thread - 0 -> Goods = 8
复制代码

这个例子之所以会得到这种结果,是因为当一个线程执行时,另一个线程插入执行。关键插入的地方可能有:
1. 在共享变量goods读/写的过程中。
2. goods++操作包含的+1、赋值等操作中。

这样的结果我们肯定是不能接受的,事实上如果操作的是非基本类型变量,那么你的程序可能会脆弱不堪,随时面临着崩溃。我们希望程序能够高效且正确的运行,就需要解决多线程场景下的通讯(信息或数据传递)和同步(有序执行)的问题。

image

多线程的通讯和同步

目前,我们大致有两套解决多线程问题的模型。
- 基于内存共享的模型。就是线程之间通过共享内存实现通讯,即共享内存中的信息是公共可见的,但需要显示的进行同步。不然就会出现上面例子中错乱的问题。不难看出,共享内存模型特点是是隐式通讯,显示同步的。Java选择的并发解决方案就是基于共享内存的。这就是为什么我们常常需要在Java使用synchronized或者Lock来进行同步操作的原因。
- 基于消息传递的模型。就是线程之间通过发送/接收消息来实现同步。由于发送消息和接收消息总是具有先后顺序的(先有发送,后有接收),所以这种模型的特点是隐式同步,显示通讯,即需要在发送消息的时候附加需要传递的信息来进行通信。Android中的Handler机制就是基于消息传递模型的。关于Handler机制CoorChice的这篇文章中有详细的讲述:【拒绝一问就懵】之从Thread讲到Handle

下面,我们了解下Java中的同步手段。

线程同步手段

synchronized

synchronized关键字相信大家都不陌生,我们常常把它加到方法或代码块上用于同步:

public synchronized void testThread() {
    ...
  }
复制代码

或者这样来同步代码块:

public void testThread() {
    Object object = new Object();
    
    synchronized (this){ //本类实例的对象锁
      ...
    }
    
    synchronized (object){ //指定的对象锁
      ...
    }
    
    synchronized (Object.class){ //类锁。注意,这表示该类所有对象实例同时只能有一个访问该代码块。
      ...
    }
}
复制代码

在进行同步时,需要时刻注意,你需要把同步加在真正需要同步的地方,而不是大段的进行同步,那样会有效降低程序效率的!记住:同步粒度尽可能的小!

Lock

与sycnhronized相比,Lock相当于是手动实现同步。在Java中,实现了一个ReentrantLock来帮助我们实现同步。使用起来也比较简单,我们只需要在需要同步的代码块前段加锁,末端释放锁即可。看个例子吧。

int goods = 0;

public void testThread() {
    Lock lock = new ReentrantLock();
    for (int i = 0; i < 3; i++) {
      new Thread(() -> {
        lock.lock();
        while (goods < 10) {
          goods++;
          System.out.println(
            Thread.currentThread().getName() +
              " -> Goods = " + goods);
        }
        lock.unlock();
      }, "Thread - " + i).start();
    }
  }
复制代码

同样是上面那个例子,这次看看运行结果吧。

Thread - 0 -> Goods = 1
Thread - 0 -> Goods = 2
Thread - 0 -> Goods = 3
Thread - 0 -> Goods = 4
Thread - 0 -> Goods = 5
Thread - 0 -> Goods = 6
Thread - 0 -> Goods = 7
Thread - 0 -> Goods = 8
Thread - 0 -> Goods = 9
Thread - 0 -> Goods = 10
复制代码

使用Lock实现同步需要注意在发生异常的地方及时释放锁,否则将会导致其它等待获取锁的线程一直阻塞下去!此外,如果使用mLock.tryLock()获取锁可以根据返回值判断是否成功获取到了锁。

final有同步作用吗?

答案是肯定的,但是它只能保证某些情况下的同步。它们是什么情况呢?就是对于不可变对象而言的。不可变对象(成员变量由基本类型或final修饰,或其它不可变对象组成的对象)意味着在安全发布后,我们不能再修改它,所以对于所有可以见到它的线程而言,它是相同的。

对于可变对象(就是非不可变对象喽,例如普通的List、Map等),即使使用了final进行修饰,在并发场景下,你仍然需要进行显示的同步。因为可变对象的内容是可以被修改的。看个例子,筒靴们可能会理解得更清晰。

final AlterableObj obj = new AlterableObj();
@Test
public void testThread_2() {
  for (int i = 0; i < 10; i++) {
    new Thread(() -> {
      while (obj.var < 100) {
        obj.var++;
        System.out.println(
          Thread.currentThread().getName() +
            " -> AlterableObj.var = " + obj.var)
      }
    }, "Thread - " + i).start();
  }
}
class AlterableObj{
  public int var = 0;
}
复制代码

运行结果比较长,我仅截取一部分能说明问题的:

...
Thread - 2 -> AlterableObj.var = 42
Thread - 2 -> AlterableObj.var = 43
Thread - 2 -> AlterableObj.var = 44
Thread - 1 -> AlterableObj.var = 40
Thread - 4 -> AlterableObj.var = 46
Thread - 4 -> AlterableObj.var = 48
Thread - 4 -> AlterableObj.var = 49
Thread - 4 -> AlterableObj.var = 50
Thread - 4 -> AlterableObj.var = 51
Thread - 3 -> AlterableObj.var = 39
...
复制代码

看,已经发生错乱了!所以fianl并不能保证不可变对象的同步。

image

volatile有同步作用吗?

++volatile的主要作用是保证被修饰变量的可见性。++ 这意味着,++被volatile修饰的变量的读/写操作类似于是原子性的++,即read和load,stroe和write过程变得连续而不可被中断。所以,某种意义上说,volatile是有同步作用的,但是范围非常小,通常不能满足我们的需求。

此外,volatile能够在一定程度上保证程序的有序性。JVM在编译时会对程序进行指令重排,但这不会影响执行结果。如果一个变量被volatile修饰,那么发生在它读/写操作之前的程序指令,一定不会被重排到它的读/写操作之后。比如:

volatile int a = 0;

int b = 1;
int c = 2;

int a = 3;

int d = 4;
int e = 5;
复制代码

上面代码中,int a = 3像一道屏障一样,使得int b = 1int c = 2一定发生在int d = 4int e = 5之前。

它们自带同步属性

java.util.concurrent包下,Java为我们提供了不少常用对象的线程安全版,比如AtomicXXX系列ConcurrentXXX系列CopyOnWriteXXX系列等。一般情况下,你可以放心的使用它们,而不用担心多线程场景下的各种麻烦问题!

使用多线程吧!

现在,筒靴们应该能够合理的使用多线程来提高程序效率了吧。

在Android中,由于主线程(UI线程)负责绘制界面,所以是万万阻塞不得!如果在主线程中不小心混入了耗时操作,后果是很可怕的。轻则导致界面卡顿,重则导致ANR!相关知识可以看看CoorChice的这篇文章:【拒绝一问就懵】之Activity的启动流程

对于复杂计算、数据读/写、网络访问等耗时操作,我们都应该放到线程中进行。现在设备通常都具备多个cpu,比如8核设备可以至少并行运行8条线程!不搞点并发操作简直是暴遣天物啊。我们只需要谨慎的处理好线程间的通讯及同步问题即可。当然,这并不像说的那么容易,需要多花点时间去思考和尝试。Java也提供了一些高效且简化的类来帮助我们合理的进行并发编程,比如CoorChice在这篇文章中介绍的:【面试必备】简单了解下ExecutorService

总结

  • 抽出空余时间写文章分享需要动力,还请各位看官动动小手 【点个赞】,给CoorChice点鼓励
  • CoorChice一直在不定期的创作新的干货,想要上车只需进到【个人主页】点个关注就好了哦。发车喽~

本篇主要介绍了关于多线程场景下一些需要注意的点,筒靴们在进行并发操作时需要根据这些特点谨慎的处理线程间的通讯和同步。

参考链接

  1. Java Volatile Keyword:http://tutorials.jenkov.com/java-concurrency/volatile.html
  2. Java内存模型(一):http://www.cloudchou.com/softdesign/post-631.html
  3. Java 多线程-可见性问题:https://mritd.me/2016/03/20/Java-%E5%A4%9A%E7%BA%BF%E7%A8%8B-%E5%8F%AF%E8%A7%81%E6%80%A7%E9%97%AE%E9%A2%98/
  4. Java多线程干货系列—(四)volatile关键字| 掘金技术征文:https://juejin.im/post/590f451c44d904007beaba1b

看到这里的童鞋快奖励自己一口辣条吧!









原文地址:访问原文地址
快照地址: 访问文章快照