Java学习笔记--线程

从我第一篇Java学习笔记系列开始,到现在所写的程序全是单线程程序,也就是程序从main()进入到结束只有一个流程,有时候我们需要设计程序拥有多个流程,也就是我要说的多线程(multi-thread)程序。


线程简介

我们先来看一个龟兔赛跑的例子(单线程实现):
题目要求:
设计一个龟兔赛跑游戏,赛程长度为10歩,每经过一秒,乌龟前进一步,兔子则可能前进两歩也有可能睡觉。

import static java.lang.System.out;

public class TortoiseHareRace {
    public static void main(String[] args){
        boolean[] flags = {true, false};
        int totalStep = 10;                  //总步数
        int tortoiseStep = 0;                //乌龟步数
        int hareStep = 0;                    //兔子步数

        out.println("龟兔赛跑开始...");
        while(tortoiseStep < totalStep && hareStep < totalStep){
            tortoiseStep++;                  //乌龟步数加一
            out.printf("乌龟跑了 %d 步...\n", tortoiseStep);

            boolean isHareSleep = flags[((int) (Math.random()* 10))% 2];   //随机睡觉
            if(isHareSleep){
                out.println("兔子睡着了");
            }else{
                hareStep += 2;
                out.printf("兔子跑了 %d 步...\n", hareStep);
            }
        }
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

再来看看多线程是怎么实现的:

乌龟:

public class Tortoise implements Runnable {
    private int totalstep;
    private int step;

    public Tortoise(int totalstep){
        this.totalstep = totalstep;
    }

    @Override
    public void run(){
        while(step < totalstep){
            step++;
            System.out.printf("乌龟跑了 %d 歩\n", step);
        }
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

兔子:

public class Hare implements Runnable {
    private boolean[] flags = {true, false};
    private int totalstep;
    private int step;

    public Hare(int totalstep){
        this.totalstep = totalstep;
    }

    public void run(){
        while(step < totalstep){
            boolean isHareSleep = flags[((int) (Math.random()* 10))% 2];

            if(isHareSleep){
                System.out.println("兔子睡着了");
            }else{
                step += 2;
                System.out.printf("兔子跑了 %d 歩\n", step);
            }
        }
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

Main函数:

public class TortoiseHareRace2 {
    public static void main(String[] args){
        Tortoise tortoise = new Tortoise(10);
        Hare hare = new Hare(10);

        Thread tortoisethread  = new Thread(tortoise);
        Thread harethread = new Thread(hare);

        tortoisethread.start();
        harethread.start();
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

从上面的代码中我们可以看到线程使用方面一些基本的语法,在这里我就不再赘述。
其实,在Java中我们想要在main()以外设计独立的流程,可以攥写操作类接口java.lang.Runnable,流程的进入点是run方法。


Thread与Runnable

一般来说,我们可以认为JVM是一台虚拟的计算机,(粗略认为)但他只安装了一颗成为主线程的CPU,可执行main()定义的执行流程,如果想为JVM加装CPU,就要创建Thread实例,要启动额外CPU就要调用Thread的start方法。额外CPU的执行流程进入点,可以定义在Runnable接口的run方法中。

除了将流程定义在Runnable的run方法中,我们还可以继承Thread类,重新定义run方法,我建议使用第一种方法,因为我们操作Runnable接口的好处就是比较有弹性,你的类还有机会继承其他类。若我们继承了Thread之后,那我们的类就只是一种Thread,一般我们为了直接利用Thread中定义的方法,才会选择继承Thread。


线程生命周期

Daemon线程

主线程会从main方法开始执行,知道main方法结束之后停止JVM。如果主线程中启动了额外的线程,默认会等待被启动的所有线程都执行完run方法才终止JVM。如果一个线程被标示为Daemon线程,在所有的非Daemon线程都结束时,JVM就会自动终止。

我们可以使用setDaemon方法来设定一个线程是否为Daemon线程,来看一个例子:

public class DaemonDemo {
    public static void main(String[] args){
        Thread thread = new Thread(){
            public void run(){
                while(true){
                    System.out.println("Orz");
                }
            }
        };

        thread.setDaemon(true);   //把此线程定义为Daemon线程
        thread.start();
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

如果我们没有使用setDaemon设定为true,那么程序会不断的输出Orz而不终止。使用isDaemon方法可以判断这个线程是否为Daemon线程。
我们默认所有的Daemon线程产生的线程也是Daemon线程。


Thread基本状态图

这里写图片描述

在调用Thread实例start方法后,基本状态为可执行(Runnable),被阻断(Blocked),执行中(Running)。

实例化Thread并执行start方法之后,线程进入Runnable状态,此时线程尚未真正开始执行run方法,必须等待排班器(Scheduler)排入CPU执行,线程才会执行run方法,进入Running状态。线程看起来是同时执行,但实际上同一时间点上,一个CPU还是只能执行一个线程,只是CPU会不断切换线程,且切换的动作很快,所以看起来像是同时执行。

线程具有优先权,可以使用Thread的setPriority方法设定优先权,可设定值在1到10之间,默认是5,超出1到10的设定值会抛出IllegalArgumentException,数字越大优先权越高,排班器越优先排入CPU,如果优先权相同,则输流执行。

有几种状况会使线程进入Blocked,如调用Thread.sleep(),进入synchronized前竞争对象锁定的阻断,调用wait方法的阻断,等待输入输出完成。运用多线程,当某线程进入Blocked时,让另一线程排入CPU执行(成为Running),避免CPU空闲下来,经常是改进效能的方式之一。

线程因为输入/输出进入Blocked状态,在完成输入/输出之后,会回到Runnable状态,等待排班器排入执行(Running状态)。一个进入Blocked状态的线程,可以由另一个线程调用该线程的interrupt方法,让他离开Blocked状态。

举个例子来说,使用Thread.sleep()会使线程进入Blocked状态,现在有其他线程调用该线程的interrupt方法,会抛出InterruptedException异常对象,这是让线程醒过来的方式。来看一个简单的范例:

public class InterruptedDemo {
    public static void main(String[] args){
        Thread thread = new Thread(){
            @Override
            public void run(){
                try{
                    Thread.sleep(99999);
                }catch (InterruptedException ex){
                    System.out.println("我醒了");
                }
            }
        };

        thread.start();
        thread.interrupt();       //主线程调用thread的interrupt()
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

安插线程

如果A线程正在运行,流程中允许B线程加入,等到B线程执行完毕之后在继续A线程流程,可以使用join方法完成这个 需求。

当线程使用join方法加入至另一线程的时候,另一线程会等待被加入的线程完成工作,然后继续它的动作。来看一个例子:

public class JoinDemo {
    public static void main(String[] args) throws InterruptedException{
        out.println("Main thread 开始...");

        Thread threadB = new Thread(() -> {
            out.println("Thread B开始...");

            for (int i = 0; i < 5; i++){
                out.println("Thread B 执行...");
            }

            out.println("Thread B 结束...");
        });

        threadB.start();
        threadB.join();          //将线程B加入主线程流程

        out.println("Main thread 将结束...");
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

有时候可能加入的线程处理太久,你不想无止境等待这个线程工作完毕,则可以在join()时指定时间,如join(10000),着表示加入流程的线程至多可以处理10000毫秒,也就是10秒,如果加入的线程还没有执行完毕就不管他了,目前线程可继续执行原本的工作流程。


停止线程

线程完成run方法之后,就会进入Dead,进入Dead(或已经调用过start方法)的线程不可以再次调用start方法,否则会抛出IllegalThreadStateException。

Thread类上有定义stop方法,不过被标示为Deprecated,这表示这些API虽然有,但由于一些原因并没有删除它,所以写程序的时候是十分不建议使用这样的API,这样的API还有像线程的暂停,重启(suspend(), resume())等,如果我们要停止线程或者是暂停,重启必须视需求操作,让线程跑完应有的流程,而非直接停止!


关于ThreadGroup

每个线程都属于某个线程组群。若在main方法中产生一个线程,该线程会属于main线程组。可以使用以下程序获得目前线程所属的线程群组名:

Thread.currentThread().getThreadGroup().getName();
 
 
  • 1

每个线程产生时,如果没有规定所属群组,则归入产生该子线程的线程群组。线程一旦归入某个群组,就无法再更换。

我们可以使用java.lang.ThreadGroup类来管理群组中的线程。可以使用以下方式产生群组,并在产生线程时指定所属群组:

ThreadGroup group1 = new ThreadGroup("group1");
ThreadGroup group2 = new ThreadGroup("group2");

Thread thread1 = new Thread(group1, "group1's member");
Thread thread2 = new Thread(group2, "group2's member");
 
 
  • 1
  • 2
  • 3
  • 4
  • 5

ThreadGroup中的某些方法,可以对群组中的所有线程产生作用。例如,interrupt方法可以中断群组中的所有线程,setMaxPriority方法可以设定群组中所有线程的最大优先权。

如果想要一次取得群组中的所有线程,可以使用enumerate方法。

Thread[] threads = new Thread[threadGroup1.activeCount()];
threadGroup1.enumerate(threads);
 
 
  • 1
  • 2

activeCount方法取得群组中的线程数量,enumerate方法要传入Thread数组,这会将线程对象设定至每个数组索引。

ThreadGroup中还有个uncaughtException方法,群组中某个线程发生异常儿未捕捉时,JVM会调用此方法进行处理。如果ThreadGroup还有父ThreadGroup,就会调用父ThreadGroup的uncaughtException方法,否则看看是否为ThreadDead实例。若是则什么都不做,若不时则调用异常的printStrackTrace方法。如果必须定义ThreadGroup中线程的异常处理行为,可以重新定义次方法,例如:

public class ThreadGroupDemo {
    public static void main(String[] args){
        ThreadGroup group  = new ThreadGroup("group") {
            @Override
            public void uncaughtException(Thread thread, Throwable throwable) {       //得到异常线程,异常信息
                System.out.printf("%s: %s\n", thread.getName(), throwable.getMessage());
            }
        };

        Thread thread = new Thread(group, () -> {
            throw new RuntimeException("测试异常");
        });

        thread.start();
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

uncaughtException方法的第一个参数可取得发生异常的线程实例,第二个参数可取的异常对象。

在JDK5之后,如果线程组之中的线程发生异常,uncaughtException方法处理顺序是:

  • 如果ThreadGroup有父ThreadGroup,就会调用父ThreadGroup的uncaughtException方法;
  • 否则看看Thread是否使用setUncaughtExceptionHandler方法设定Thread.UncaughtExceptionHandler实例,有的话就会调用其uncaughtException方法;
  • 否则看看异常是否为ThreadDead实例,若是的话则什么都不做,若不是的话调用异常的printStrackTrace方法。

synchronized与volatile

如果一个类会使线程存取同一对象相同资源时因发竞速现象,我们就说这个类是不具备线程安全的类,具体的例子我不在贴出,有疑问的读者可以自行百度。


使用synchronized

每个对象都会有个内部锁定,或称为监控锁定。被标示为synchronized的区块将会被监控,任何线程要执行synchronized区块都必须先取得指定的对象锁定。如果线程A已经取得对象锁定开始执行synchronized区块,B线程也想执行synchronized区块,会因无法取得对象锁定而进入等待锁定的状态,直到A线程释放锁定,B线程才有机会取得锁定对象儿执行synchronized区块。

实际上在等待对象锁定的时候,线程也会进入Blocked状态。线程若因尝试执行synchronized区块而进入Blocked,在取得锁定之后,会先回到Runnable状态,等待CPU排班器排入Running状态。

在之前讨论的Collection与Map都未考虑线程安全,可以使用Collections的synchronizedCollection(),synchronizedList(),synchronizedSet(),synchronizedMap()等方法,这些方法会将传入的Collection,List,Set,Map操作对象打包,返回具线程安全的对象。例如我们如果经常对List进行添加和移除的工作:

List<String> list = new ArrayList<>();
synchronized(list){
    ...
    list.add("...");
}
...
synchronized(list){
    ...
    list.remove("...");
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

那么我们可以这样简化:

List<String> list = Collection.synchronizedList(new ArrayList<String>());
 
 
  • 1

在Java中的synchronized提供的是可重入同步,也就是线程取得某对象锁定之后,若执行的过程中又要执行synchronized,尝试取得锁定的对象来源又是同一个,可以直接执行。

因为线程无法取得锁定时会造成阻断,所以不正确的使用synchronized有可能造成效能低落,另一个问题则是死结。例如:有些资源在多线程下交叉使用,就有可能造成死结。来看一个例子:

class Resource{
    private String name;
    private int resource;

    Resource(String name, int resource){
        this.name = name;
        this.resource = resource;
    }

    String getName(){
        return name;
    }

    synchronized int doSome(){
        return ++resource;
    }

    synchronized void cooperate(Resource resource){
        resource.doSome();
        System.out.printf("%s: 整合 %s 的资源\n", this.name, resource.getName());
    }
}

public class DeadLockDemo {
    public static void main(String[] args){
        Resource resource1 = new Resource("resource1", 10);
        Resource resource2 = new Resource("resource2", 20);

        Thread thread1 = new Thread(() -> {
            for(int i = 0; i < 10 ; i++){
                resource1.cooperate(resource2);
            }
        });

        Thread thread2 = new Thread(() -> {
            for(int i = 0; i < 20 ; i++){
                resource2.cooperate(resource1);
            }
        });

        thread1.start();
        thread2.start();
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44

上面的程序发生死结是几率问题。多次执行后会发现,又是程序可顺利执行完成,有时程序会整个停顿。

造成上面的原因在于,thread1调用resource1.corportate(resource2)时,thread1会取得resource1的锁定,若此时thread2也调用resource2.corportate(resource1),就会取得resource2的锁定,凑巧的是thread1现在打算运用穿入的resource2调用doSome(),那么它就会尝试取得resource2的锁定,但是现在resource2的锁定在thread2手上,所以thread1就会被阻塞,相同的,resource1被传入resource2的方法之中,那么现在thread2就会尝试取得resource1的锁定,但是resource1的锁定在thread1手上,所以thread2也会被阻塞,最后就造成死结的情况。

我们在程序设计中,应尽量避免死结的发生。


使用volatile

synchronized要求达到所标示区块的互斥性与可见性,互斥性是指synchronized区块同时间只能有一个线程,可见性是指线程离开synchronized区块后,另一线程接触到的就是上一线程改变后的对象状态。

对与可见性的要求可以使用volatile达到变量范围。在讨论变量的可见性之前,我们先来看一个例子:

class Variable1 {
    static int i = 0, j = 0;

    static void one(){
        i++;
        j++;
    }

    static void two(){
        System.out.printf("i = %d, j = %d\n", i, j);
    }
}

public class Variable1Test{
    public static void main(String[] args){
        Thread thread1 = new Thread(() -> {
            while(true){
                Variable1.one();
            }
        });

        Thread thread2 = new Thread(() -> {
            while(true){
                Variable1.two();
            }
        });

        thread1.start();
        thread2.start();
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

在这个程序运行之后有可能出现j远大于i的结果。当thread2调用Variable1.two()取得i值之后,有可能切换thread1不断执行Variable1.one()多次,在切回thread2,就有可能发生这种情况。

我们可以像之前那样给one,two方法上标示synchronized,这样每次thread1调用one时,thread2就必须等待thread1释放锁定,才能调用two,thread2调用two时,thread1就必须等待thread2释放锁定,才能调用one。这样的确可以解决问题,但是当我们调用one时,其他线程就不能调用two,反之亦然。这样会看到执行速度明显变慢。

对于为何j会大于i,这和线程的“快取”有关,在这里不详细描述,要阻止这种情况的发生,我们可以在变量上声明volatile,表示变量是不稳定的,易变的,但即使这样,也只是提高了i,j相等的几率。被标示为volatile的变量不允许线程快取。实际上,如果我们要保证i,j相等的话,就要使用synchronized。


等待与通知

wait(),notify(),notifyAll()是Object定义的方法,可以通过这三个方法控制线程释放对象的锁定,或者通知线程参与锁定竞争。

在线程执行synchronized范围的程序代码期间,若调用锁定对象的wait方法,线程会释放对象锁定,并进入对象等待集合而处于阻断状态,其他线程可以竞争对象锁定。

放在等待集合的线程不会参与CPU排班,wait()可以指定等待时间,时间到了之后线程会再次加入排班,如果指定时间0或不指定,则线程会持续等待,直到被中断(调用interrupt())或是告知(notify())可以参与排班。

被竞争锁定的对象调用notify方法时,会从对象等待集合中随机通知一个线程加入排班,如果调用notifyAll方法,所有等待集合中的线程都会被通知参与排班。

线程调用对象wait方法时,会先让出synchronized区块的使用权并等待通知,或是等待指定时间,直到被notify方法或时间到时,(取得对象锁定之后)在从调用wait()处开始执行。

来举一个生产者,店员,消费者之间的例子,消费者每次生产一个int整数交给店员:

public class Producer implements Runnable{
    private Clerk clerk;

    public Producer(Clerk clerk){
        this.clerk = clerk;
    }

    public void run(){
        System.out.println("生产者开始生产整数... ...");

        for(int product = 1; product <= 10; product++){
            try{
                clerk.setProduct(product);        //将产品交给店员
            }catch (InterruptedException ex){
                throw new RuntimeException(ex);
            }
        }
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

程序中使用for循环生产1~10的整数,Clerk代表店员,可通过setProduct方法将生产的整数交给店员。

消费者从店员处取走int整数:

public class Consumer implements Runnable {
    private Clerk clerk;

    public Consumer(Clerk clerk){
        this.clerk = clerk;
    }

    public void run(){
        System.out.println("消费者开始消耗整数... ...");

        for(int i = 1; i <= 10; i++){
            try{
                clerk.getProduct();        //从店员处取走产品
            }catch (InterruptedException ex){
                throw new RuntimeException(ex);
            }
        }
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

程序中使用for循环来消费10次整数,可通过Clerk的getProduct方法,从店员处取走整数。

店员一次只能持有一个int整数,必须尽到要求等待与通知的职责:

public class Clerk {
    private int product = -1;

    public synchronized void setProduct(int product) throws InterruptedException{
        waitIfFull();       //看看店员有没有空间收产品,没有的话就稍后
        this.product = product;
        System.out.printf("生产者设定 %d \n", this.product);
        notify();         //通知等待中的线程(消费者)
    }

    private synchronized void waitIfFull() throws InterruptedException{
        while(this.product != -1){         //店员由产品,没有空间
            wait();
        }
    }

    public synchronized int getProduct() throws InterruptedException{
        waitIfEmpty();              //看看店员有没有货,没有的话就稍后
        int p = this.product;
        this.product = -1;           //表示货物被取走
        System.out.printf("消费者取走 %d \n", p);
        notify();                  //通知等待集合中的线程(生产者)

        return p;
    }

    private synchronized void waitIfEmpty() throws InterruptedException{
        while(this.product == -1){
            wait();      
        }
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

因为线程有可能在未经notify方法,interrupt方法或逾时情况下私自苏醒,所以wait方法一定要在条件式成立的循环中执行。

用以下程序示范生产者,消费者,店员:

public class ProducerConsumerDemo {
    public static void main(String[] args){
        Clerk clerk = new Clerk();

        new Thread(new Producer(clerk)).start();
        new Thread(new Consumer(clerk)).start();
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

程序的运行结果希望大家自己能够尝试一下。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值