多线程--基础部分

1 启动多线程

多线程的启动方式有两种,一种是继承Thread类并覆盖run()方法,另一种是实现Runnable接口中的run()方法。

1.1 继承Thread类

对于需要执行的任务语句,我们可以放入run()方法之中,并调用start()方法进行执行。如:

public class MyThread extends Thread {
    public void run() {
        //doSomething;
    }
}

public class Test{
    public static void main(String[] arg){
        MyThread myThread = new MyThread();
        myThread.start();
    }
}

1.2 实现Runnable接口

方法如下:

public class MyRunnable implements Runnable {
    public void run() {
        //doSomething;
    }
}

public class Test{
    public static void main(String[] arg){
        MyRunnable myRunnable = new MyRunnable ();
        new Thread(myRunnable).start();
    }
}

2 线程的状态

这里写图片描述
摘自:第十一章 Android 的线程与线程池
值得说明的是,Runnable状态包含了就绪ready和正在运行running两种情况。

通常,Java的线程状态是服务于监控的,如果线程切换得是如此之快,那么区分 ready 与 running 就没什么太大意义了。

进一步

详见:Java线程状态–肖国栋

3 多线程的操作方法

3.1 join()

参考:Java多线程中join方法的理解
在线程B内对线程A调用该方法,会使得线程B阻塞,直至A线程完成,B才会继续执行下去。

public class MyThread implements Runnable {
    private String name;
    public MyThread(String n) {
        name = n;
    }
    public void run() {
        System.out.println(name + "Begin");

        System.out.println(name + "Over");
    }

}

public class Test {

    public static void main(String[] args) {
        MyThread myThreadA = new MyThread("A");
        MyThread myThreadB = new MyThread("B");
        Thread aThread = new Thread(myThreadA);
        Thread bThread = new Thread(myThreadB);
        aThread.start();

        try {
            aThread.join();
        }catch(InterruptedException e){

        }
        System.out.println("main is waiting");
        bThread.start();

        try {
            bThread.join();
        }catch(InterruptedException e){

        }

        System.out.println("main is Over");
    }

}

输出结果为:

ABegin
AOver
main is waiting
BBegin
BOver
main is Over

从结果可以看出,当A调用join时,main被阻塞,直到A结束,main才得以继续运行。

进一步

join方法是一个synchronized方法,内部通过调用wait()来实现阻塞main线程。并且,由于阻塞了main线程,因此join方法会抛出InterruptedException异常,用于在A线程中断时通知main线程进行处理。

3.2 static sleep(long millis)

该方法是一个native static的方法,因此其实现方式并不是Java语言。另一方面,由于该方法是一个静态方法,所以只能对当前活动的线程使用。该方法会抛出IE异常,因此要用try/catch块覆盖。
使用方法如下:

扫描二维码关注公众号,回复: 920782 查看本文章
public class MyThread implements Runnable {
    private String name;
    public MyThread(String n) {
        name = n;
    }
    public void run() {
        System.out.println(name + "Begin");
        try {
            Thread.sleep(10000L);
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
        System.out.println(name + "Over");
    }

}

进一步

sleep方法接受的是长整型数,因此数字后要加L;
对于进入synchronized区域内时,sleep方法使得线程进入的是TIMED_WAITING状态,并且不会释放线程持有的锁;无时间参数的wait方法使得线程进入的是WAITING状态(带时间参数的也是TIMED_WAITING状态),并且要求调用wait方法时,线程必须先获得锁(必须进入临界区),然后通过调用wait方法释放锁。
更多的差别详见:What is difference between wait and sleep in Java Thread?

3.3 static yield()

yield方法用于将当前线程从运行状态转为就绪状态。使之重新和其他线程竞争。该方法主要用于debug之中。

3.4 interrupt()

参考:
详细分析Java中断机制
Java里一个线程调用了Thread.interrupt()到底意味着什么?
处理 InterruptedException

线程中止的情况

  • 当线程的run方法执行完最后一条语句或经由return语句返回
  • 出现了异常并且该异常未被捕获

interrupt()方法

具体来说,当对一个线程,调用 interrupt() 时,
1. 线程在sleep或wait,join,此时如果别的进程调用此进程的 interrupt()方法,此线程会被唤醒并被要求处理InterruptedException;(thread在做IO操作时也可能有类似行为,见java thread api)
2. 此线程在运行中,则不会收到提醒。但是 此线程的 “打扰标志”会被设置, 可以通过isInterrupted()查看并作出处理。
作者:郭无心
链接:https://www.zhihu.com/question/36771163/answer/68974735

根据情况①,我们可以发现,这就是为什么join,sleep等使得线程进入阻塞状态的方法要抛出IE异常的原因了。因为在阻塞/等待状态时,如果线程被中断,就要采取措施,而采取措施的方法,就是先用try/catch块包含join等方法,在catch块中采取解决当前线程被突然中断时的措施。例子如下:

public class MyThread implements Runnable {
    private String name;
    public MyThread(String n) {
        name = n;
    }
    public void run() {
        System.out.println(name + "Begin");
        try {
            Thread.sleep(10000L);
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            System.out.println(name + " is interrupted");//添加的措施
            return;
        }
        System.out.println(name + "Over");
    }

}

public class Test {

    public static void main(String[] args) {
        MyThread myThreadA = new MyThread("A");
        MyThread myThreadB = new MyThread("B");
        Thread aThread = new Thread(myThreadA);
        Thread bThread = new Thread(myThreadB);
        aThread.start();
        aThread.interrupt();   //改动的地方
        try {
            aThread.join();
        }catch(InterruptedException e){

        }

        System.out.println("main is waiting");
        bThread.start();

        try {
            bThread.join();
        }catch(InterruptedException e){

        }

        System.out.println("main is Over");
    }

}
//-------------------结果如下------------------------
ABegin
A is interrupted
main is waiting
BBegin
BOver
main is Over

注意

在遇到抛出中断异常的时候,我们有两种处理方法:

  1. 捕获异常,处理,然后再抛出
  2. 直接向上层抛出

不应该吞掉异常,这会对上层调用方法的判断造成影响。再次抛出要求该方法再方法名后注明了throws InterruptedException,如果没有注释,我们也可以采用捕获,然后对当前线程进行中断的方法,即

try{
    sleep(delay)
}catch{
    Thread.currentThread().interrupt();
}

针对情况②,我们采用了两种方法去判断:

isInterrupted()方法
该方法通过Thread.currentThread().isInterrupted()方式调用,可以检查当前线程的中断状态是否为true。
static Interrupted()方法
该方法和isInterrupted()方法类似,不过调用该方法会在获取中断状态后,将中断状态重新置为false,该方法主要用于当我们仍有一些任务需要处理时,可以暂时忽略这次中断,并检测下一次中断。

这两个方法常常用在while循环中进行不断的检测,以保证随时响应中断请求:

public void run(){
    try{
        while(Thread.currentThread().isInterrupted() != true){
            //正常操作
        }

        //while中断时可能会采取的操作,也可以放在finally块中
    }catch(InterruptedException e){
        //中断时采取的操作,对应情况①
    }finally{
        //收尾工作
    }
}

4 同步

互斥访问

首先从互斥访问的概念开始,互斥访问就是说,对于同一个变量α,当线程A访问的时候,B线程就必须等待,直到A线程访问完成,B线程才可以访问。就像一个取款机α,每个人A,B,C要排队等待依次取钱,不能一次进入多个人去取钱。

监视器概念

再来说下监视器的概念。对于互斥访问,存在一个问题。比如当A再取钱的时候,忘记密码了,在打电话问他家人密码,这样占着取款机不用,对于互斥访问,后面的人只能干等。但是有了监视器就不一样了,在监视器模式里面,如果A在打电话,那么就会把A放在等待区,让后面的人接着用取款机。等到A打完电话了,再把A放回队列,重新排队。为什么不直接放回队列,而要单独放在等待区呢?因为也许A会打很久的电话,如果再队列里面又排到他了,但是他还没打完电话,那也是白费。
参考:监视器–JAVA同步基本概念

4.1 锁对象

锁对象的存在,一个原因就是为了实现互斥访问的。就好比,一个线程A要使用变量α,那么他就要拿着α的锁,让别人进不来。这样才能实现互斥访问。
在Java里面,每个类和类的实例对象都有一把自己的锁。一旦有线程A拿到了相应的锁,比如实例α的锁,那么别的线程如果想要访问α就要先检查能否拿到α的锁,这时候肯定会发现锁已经被拿走了,那么这些线程只好等待锁还回来。这样就保证了互斥访问。
万一线程A暂时完不成任务,没法结束。按照监视器模式,他就会乖乖把锁让出来,自己去等待去呆着,等待合适条件再回来继续他的任务。

4.2 synchronized关键字

在Java里面,拿锁还锁的操作是通过synchronized关键字实现的。
synchronized关键字可以加在静态方法,类方法已经方法内部的代码块前。
静态方法
当一个线程访问加有synchronized关键字的静态方法时,自动获得该静态方法所属类的类锁。一旦拥有了类锁,那么该线程就可以访问所有该类的静态方法,对应的,其他线程对于该类的静态方法访问都会处于block阻塞状态。
实例方法
类似于访问静态方法,不过,访问实例方法时,获得的是这个实例的锁,他和类锁不冲突,比如一个线程A用于类a的实例α的实例锁,那么线程B还是可以访问类a的静态方法的,因为这两个锁不一样。
代码块
对于加在代码块上的synchronized关键字,关键字后还要加上对象锁的名称,形如:

public void function(){
    //some code
    synchronized(this){//实例锁
        //some code
    }
    synchronized(this.class){//类锁
        //some code
    }

    boolean a = 0;
    synchronized(a){//实例锁
        //some code
    }
    synchronized(boolean.class){//类锁
        //some code
    }
    //some code
}

获得相应的锁后,就类似实例方法和静态方法的访问权限了。不过要注意的是,如果括号里面不是this而是a这样的其他不相关实例/类,则相当于这个不相关的实例/类起到客户端锁定的作用。一般不建议这么做。

4.3 多线程间的通信

synchronized关键字和对象锁共同构成了互斥访问。那么如何实现监视器模式呢?Java的内嵌模式是采用wait(),notify(),notifyAll()方法。当一个线程A获得α的锁后,如果需要满足某个条件可以采用如下形式进行判断:

synchronized function() throws InterruptedException {
    try{
        while(!condition){
            wait();
        }
    }
    //next work
}

使用while而不是if进行判断的原因是:当线程A被唤醒而仍然不满足条件时,可以继续保持等待。
当使用wait后,线程会释放他获得的当前的锁并进入睡眠。注意是当前的锁,不是所有的锁!因此一个线程若持有多把锁,当他进入wait时候就很容易造成死锁。
详见:wait() releases all locks?

class Test extends Thread {
       Object locka, lockb;
    //
       Test(Object obja, Object objb) {
          this.locka = obja;
          this.lockb = objb;
       }
    //
       public void run() {
          synchronized (locka) {
             synchronized (lockb) {
                System.out.println(Thread.currentThread().getName()
                   + " holds locka= " + Thread.holdsLock(locka));
                System.out.println(Thread.currentThread().getName()
                   + " holds lockb= " + Thread.holdsLock(lockb));
                try {
                    System.out.println(Thread.currentThread().getName()
                           + " will release locka and go to wait");
                   locka.wait();
                } catch (InterruptedException e) {
                   e.printStackTrace();
                }
             }
          }
       }
    //
       public static void main(String[] args) {
          Object locka = new Object();
          Object lockb = new Object();
          new Test(locka,lockb).start();
    //
          try {
            Thread.sleep(1000L);
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }

          System.out.println(Thread.currentThread().getName()
             + " attempting to acquire locka");
    //
          synchronized (locka) {
             System.out.println(Thread.currentThread().getName()
                + " holds locka= " + Thread.holdsLock(locka));
             System.out.println(Thread.currentThread().getName()
                + " attempting to acquire lockb");
    //
             synchronized (lockb) {
                System.out.println(Thread.currentThread().getName()
                   + " holds locka= " + Thread.holdsLock(locka));
                System.out.println(Thread.currentThread().getName()
                   + " holds lockb= " + Thread.holdsLock(lockb));
                lockb.notify();
             }
          }
       }
    }
//---------------------------------结果
Thread-0 holds locka= true
Thread-0 holds lockb= true
Thread-0 will release locka and go to wait
main attempting to acquire locka
main holds locka= true
main attempting to acquire lockb

可以发现上述情况子线程并未放出所有的锁,因此造成了死锁。

还有要注意的是,wait方法会抛出IE异常,因此要用try/catch包住并在catch块中处理异常。
notify和notifyAll都会唤醒等待的线程,使之变为runnable状态。区别就在于notify只会唤醒单一等待进程(容易陷入死锁),而notifyAll会唤醒所有的等待进程。

5 volatile域

参考:
Java并发编程:volatile关键字解析
正确使用 Volatile 变量

Java 语言中的 volatile 变量可以被看作是一种 “程度较轻的 synchronized”;与 synchronized 块相比,volatile 变量所需的编码较少,并且运行时开销也较少,但是它所能实现的功能也仅是 synchronized 的一部分。

锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)。互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据。可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的 —— 如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前的值或不一致的值,这将引发许多严重问题。

Volatile 变量具有 synchronized 的可见性特性,但是不具备原子特性。这就是说线程能够自动发现 volatile 变量的最新值。Volatile 变量可用于提供线程安全,但是只能应用于非常有限的一组用例:多个变量之间或者某个变量的当前值与修改后值之间没有约束。

引自:正确使用 Volatile 变量

5.1 Java内存模型

Java内存区域如下图所示,详细见:
Java内存区域——堆,栈,方法区等
这里写图片描述
当线程调用一个引用变量时,会从堆中将变量的具体参数读入,而堆中的数据一般都在主存当中,这就相当于从主存当中读取数据到栈帧所在的高速缓存中。在这一个过程会涉及到原子性问题。接下来将会阐述并发当中会涉及到的几个概念。

5.2 原子性

原子性指的是不可打断的特性,而对于代码i++这个操作,就是一个非原子性操作。他其实经历了3个部分:1.读取当前i值;2.将当前i值加一;3.将结果存回i中。对于单线程,这没什么问题。但是对于多线程来说,很有可能出现错误,如AB两个线程都执行i++,线程A读取了i=1,此时线程B也读取了i=1,接下来A中i变为2,B中i变为2,A返回2至i,B也返回2至i,i最终等于2,这就无形少加了一次。在并发的环境中,由于忽略了命令没有原子性,而很有可能造成错误的结果。

5.3 缓存一致性

还是i++的问题,这次,假设A先完成了i++的操作,但是,由于他只是储存在自己的栈帧中,还没有写入堆中,这是如果B开始进行i++的操作,最终结果也是只加了一次。因此锁的功能就是保证操作的原子性和可见性。即操作要一次性由一个线程执行完全。并且执行完成后立刻刷新主存的值,保证所有读取该值的操作都能接受到新值。

5.4 指令重排序

在Java内存模型中,允许编译器和处理器对指令进行重排序。指令重排序的意思如下:

int a = 10;
int b = 100;
int a = a + b;
int d = a + b;

上述代码段可能会被重排为:

int b = 100;
int a = 10;
int a = a + b;
int d = a + b;

但是不会变为(这样结果发生了错误)

int a = 10;
int b = 100;
int d = a + b;
int a = a + b;

但是在多线程中,指令重排序则可能会产生错误。比如:(摘自:Java并发编程:volatile关键字解析

//线程1:
context = loadContext();   //语句1
flag= true;             //语句2

//线程2:
while(!flag ){
  sleep()
}
doSomethingwithconfig(context);

有可能语句2会在语句1之前执行,那么久可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。

为了保证指令重排后结果的正确性,JVM会依据happens-before(先行发生)原则进行判断,当两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。原则如下:

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

5.5 volatile关键字

通过加入volatile关键字,我们可以确保该变量一旦变更就会直接写入主存,并通知所有调用该变量的线程。
从上述的三种性质分析,volatile关键字不保证原子性,保证缓存一致性,部分保证指令有序性。
volatile关键字的实现是靠jvm里面的内存屏障指令实现的。

内存屏障

内存屏障(memory barrier)是一个CPU指令。基本上,它是这样一条指令: a) 确保一些特定操作执行的顺序; b) 影响一些数据的可见性(可能是某些指令执行后的结果)。编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。

内存屏障(memory barrier)和volatile什么关系?上面的虚拟机指令里面有提到,如果你的字段是volatile,Java内存模型将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令。这意味着如果你对一个volatile字段进行写操作,你必须知道:1、一旦你完成写入,任何访问这个字段的线程将会得到最新的值。2、在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。

摘自:为什么volatile不能保证原子性而Atomic可以?

再看让一个volatile的integer自增(i++),其实要分成4步:1)读取volatile变量值到local; 2)增加变量的值;3)把local的值写回;4)加入内存屏障,并让其它的线程可见。这4步的jvm指令为:

mov         0xc(%r10),%r8d ; Load
inc         %r8d           ; Increment
mov         %r8d,0xc(%r10) ; Store
lock addl   $0x0,(%rsp) ; StoreLoad Barrier

不保证原子性,通过内存屏障功能,我们可以看到,自增一的3步操作仍然不具备原子性。因此volatile不具备原子性。
保证缓存一致性,根据内存屏障的一个功能就是强制刷新CPU缓存,并且读前加屏障,写后加屏障,这就保证了缓存一致性。
部分保证指令有序性,也是因为内存屏障,屏障前的指令不可能在屏障后执行,反之亦然。因此可以保证部分指令的有序性。但是对于volatile之前的指令之间的顺序是没有保证的。

5.6 使用volatile关键字的场景

标记状态量

缓存一致性的应用:

volatile boolean flag = false;
//线程1:
while(!flag){
    doSomething();
}
//线程2:
public void setFlag() {
    flag = true;
}

防止指令重排的应用:

volatile boolean inited = false;
//线程1:
context = loadContext();   
inited = true;             

//线程2:
while(!inited ){
sleep() 
}
doSomethingwithconfig(context);

double check设计模式

class Singleton{
    private volatile static Singleton instance = null;

    private Singleton() {

    }

    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

6 CAS操作

针对volatile关键字不能进行原子性操作的缺点,提出了CAS操作方法。
具体参看:原子操作(CAS)

Compare And Set(或Compare And Swap),CAS是解决多线程并行情况下使用锁造成性能损耗的一种机制,CAS操作包含三个操作数——内存位置(V)、预期原值(A)、新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

在java中可以通过锁和循环CAS的方式来实现原子操作。Java中 java.util.concurrent.atomic包相关类就是 CAS的实现

值得注意的是原子操作也存在自己的问题,主要是三个方面:ABA问题、循环时间长开销大、只能保证一个共享变量的原子操作。具体的也可以参看上述那篇文章。

乐观锁,悲观锁

乐观锁与悲观锁。独占锁是一种悲观锁,而 synchronized 就是一种独占锁,synchronized
会导致其它所有未持有锁的线程阻塞,而等待持有锁的线程释放锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。而乐观锁用到的机制就是CAS。

7 总结

Java的多线程在一定情况下可以提高CPU效率,但是多线程对同一变量的访问可能会造成竞争状态,为此可以通过线程间的沟通或者同步的方式解决。要使用同步,可以使用synchronized关键字和wait等方法组合使用。不过作为一种重量级锁,同步会降低程序的执行效率,带来阻塞。为改善阻塞情况,我们在一些特点的场合可以把同步改为使用volatile关键字。另一方面,同步作为悲观锁的一种,我们也可以采用CAS操作等乐观锁的方式去解决。
为了完善和改进synchronized关键字和wait方法的同步作用,在Java中有一个相关的类库可以调用,这将在下一篇文章中进行总结。

猜你喜欢

转载自blog.csdn.net/timemagician/article/details/80175699
今日推荐