Java学习笔记:多线程基础

这里不多说线程和进程的区别了,直接看线程在Java中如何使用。


1、线程的创建和启动方式

1.1 继承Thread类创建线程类

让一个类继承Thread,并重写其中的run()方法,该run()方法中定义的就是线程需要做的事。所以run()方法是线程执行体。

以上是定义一个线程类,要让线程类执行还需要创建这个类的实例,并执行实例的start()方法启动该线程。定义和启动的示例代码如下。

public class FirstThread extends Thread{
    private int i;
    public void run(){
        for(; i < 20; i++){
            System.out.println(this.getName() + " " + i);
        }
    }
    public static void main(String[] args) {
        for(int j = 0; j < 100; j++){
            System.out.println(Thread.currentThread().getName() + " " + j);
            if(j == 20){
                new FirstThread().start();
                new FirstThread().start();
            }
        }
    }
}

这段代码的输出如下
在这里插入图片描述

这段代码的入口是main函数。main函数开始运行时就启动了一个主线程,从上图中可以看出主线程的名字是main。主线程启动后就开始循环输出j。当j=20时,主线程就会开启两个线程。当然这两个线程开启后不一定会马上开始执行,于是主线程和另外两个线程开始随机执行。

上面的代码中涉及到了两个方法,可能比较常用。

  • Thread.currentThread():这是Thread的静态方法,用来返回当前正在执行的线程对象。
  • getName():该方法是Thread的实例方法,返回该线程的名字。默认情况下,主线程名字是main,其它线程创建顺序不同名字依次是Thread-0,Thread-1…等。

虽然同一个进程中的子线程是可以共享变量的,但是继承于Thread类的线程类A,我们多次调用A的start()方法,即启动了多个线程,这些线程的成员变量不是共享的,每个线程各自拥有属于自己的成员变量。就像上面例子中Thread-1和Thread-0的i不是共享的。

1.2 实现Runnable接口创建线程类

实现Runnable接口,并重写其中的run()方法,该run()方法和继承Thread类中的一样。

创建该类的实例,然后将实例作为参数放到Thread类的构造函数中构造出新的线程,然后调用线程的start()方法启动线程。示例代码如下。

public class FirstThread implements Runnable{
    private int i;
    public void run(){
        for(; i < 20; i++){
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
    }
    public static void main(String[] args) {
        for(int j = 0; j < 100; j++){
            System.out.println(Thread.currentThread().getName() + " " + j);
            if(j == 20){
                FirstThread runnable = new FirstThread();
                new Thread(runnable, "新线程1").start();
                new Thread(runnable, "新线程2").start();
            }
        }
    }
}

代码的执行结果如下
在这里插入图片描述

Thread的构造函数可以对线程命名,如代码所示。

这里有一点是需要注意到的,就是我们通过继承Thread类定义一个线程类A后,多次调用A的start()方法,即创建多个线程。那么这些线程之间是不会共享实例变量的,所以继承Thread类的线程类的输出结果如图一所示,两个线程的i并不是同一个i。而实现Runnable接口的线程类的实例之间是互相共享实例变量的,所以右图中的输出两个线程的i是连续的
在这里插入图片描述

1.3使用Callable和Furture创建线程

Callable可以看成是Runnable的增强版。Callable提供了一个call()方法作为线程执行体,并且call()方法可以有返回值并且可以抛出异常。call()方法对于Callable而言就是Runnable中的run()方法,call()方法里定义线程的逻辑。

刚刚说call()方法可以有返回值。Callable是个泛型接口,Callable,T就是call()方法的返回值的类型。

但Callable并不能像Runnable那样直接作为Thread的参数构造出新线程。Callable想要构造出新线程必须通过FutureTask类。下面来看看FutureTask如何构造一个线程。FutureTask实现接口Future。

创建线程之前先定义一个FutureTask实例。

FutureTask类的其中一个构造函数如下图所示在这里插入图片描述

Callable是函数式接口,并且唯一的抽象方法call()就是定义线程逻辑,所以为了简单点写代码,我们用Lambda表达式实例化FutureTask。(有关Lambda表达式和函数式接口可以看这篇文章)

FutureTask<Integer> futureTask = new FutureTask((Callable<Integer>) () -> {
    int i = 0;
    for(; i < 100; i++){
        System.out.println(Thread.currentThread().getName() + "的i的值是" + i);
    }
    return i;
});

有了FutureTask实例后,就可以像Runnable那样启动一个线程,所以整个线程的demo如下所示

FutureTask<Integer> task = new FutureTask((Callable<Integer>) () -> {
    int i = 0;
    for(; i < 100; i++){
        System.out.println(Thread.currentThread().getName() + "的i的值是" + i);
    }
    return i;
});
for(int i = 0; i < 100; i++){
    System.out.println(Thread.currentThread().getName() + "的i的值是" + i);
    if(20 == i){
        //新建线程并启动
        new Thread(task, "有返回值的线程").start();
    }
}

执行结果如下图所示
在这里插入图片描述

前面说了线程是有返回值的,Future接口提供了好几个方法来控制它关联的Callable任务,其中就包括获取返回值。

  • V get():返回Callable任务里的call()方法的返回值。调用该方法会导致程序阻塞,必须等到子线程结束后才会得到返回值。






2、线程的生命周期

线程的生命周期是:新建、就绪、运行、阻塞、死亡。

2.1 新建和就绪

当new一个线程时,该线程就处于新建状态,和其它普通Java对象一样,Java虚拟机只是给它分配内存并初始化成员变量的值。这时的线程并没有表现出线程的动态特征。

线程对象调用start()方法后,线程就处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器。处于这个状态的线程还没运行,但随时可能进入运行状态,具体情况要看Java虚拟机的调度。

注意是使用start()方法启动线程,而不是run()。而且一旦直接调用了 run()方法,那这个线程对象就只会被看成是普通对象,即使之后再调用start()方法也没用了。

2.2运行和阻塞

一个CPU在同一时间只能有一个线程处于运行状态。

发生以下情况时,线程会发生阻塞。

  • 线程调用sleep()方法主动放弃占有的资源。
  • 线程调用了阻塞式的I/O方法,在方法返回之前,线程被阻塞。
  • 线程试图获取一个同步监听器,而这同步监听器正被其它线程锁持有。
  • 线程在等待某个通知(notify)。
  • 程序调用了线程的suspend()方法将线程挂起。但这个方法容易引起死锁,所以应该尽量避免使用该方法。

正在执行的程序被阻塞之后会在何时的时间进入就绪状态。针对上面的几种情况,发生以下的事件会让线程进入就绪状态。

  • 调用sleep()的线程经过了指定时间。
  • 线程调用的阻塞式I/0方法已经返回。
  • 线程成功地获取了试图取得的同步监视器。
  • 线程正在等待某个通知时,其它线程发出了一个通知。
  • 处于挂起状态的线程调用了resume()回复方法。

2.3线程死亡

当发生下面三种情况时,线程会死亡。

  • run()或call()方法执行完成,线程正常结束。
  • 线程抛出一个为捕获的Exception或Error。
  • 直接调用该线程的stop()方法来结束该线程,但这方法容易引起死锁。

为了测试某个线程是否已经死亡,可以调用线程对象的isAlive()方法,当线程处于就绪,运行,阻塞三种状态时,该方法返回true;当线程处于新建,死亡两种状态时,方法返回false。

对于已死亡的线程和已经启动的线程,若再次调用start()方法,会抛出IllegalThreadStateException异常。






3、控制线程

这一节讲的是如何控制控制线程的状态,比如让某个线程获得更多的执行机会(设置优先级),线程中断以后处理方式等等。

3.1 线程的 join()方法:暂停,让我先行

Thread提供了让一个线程等待另一个线程的方法,join()方法。当一个线程A的执行体中新建了另一个线程B,并启动线程B的start()方法,然后执行线程B的join()方法,那线程A会一直等待线程B执行完毕后,才会开始重新执行未完成的代码。

注意,以上的过程必须是按顺序的,线程B要先执行start()方法后才能执行join()方法。

另外,关于执行体的解释需要详细说一下。这里的执行体在代码中是指两个地方:一是主函数的psvm代码块,二是线程的run()方法里。

第一种是主程序中定义了一个线程A,执行这个线程A的join()方法时,主程序的执行会暂停,一直等到线程A结束后才开始执行。网上的很多例子介绍线程的join()方法时,举的例子都是第一种情况。
但第二种情况很少见,我担心线程A的run中执行线程B的join()方法时,出现的结果不符合预期,于是我做了以下测试。

public class Test {
    public static void main(String[] args) {
        new Thread(() -> {
            for(int i = 0; i < 20; i++){
                System.out.println("子线程A " + i);
                if(i == 10){
                    Thread thread = new Thread(() -> {
                        for(int j = 0; j < 20; j++){
                            System.out.println("子线程B " + j);
                        }
                    }, "子线程B");
                    thread.start();
                    try{
                        thread.join();
                    } catch(Exception e){}
                }
            }
        }, "子线程A").start();
    }
}

输出结果如下,线程B在线程A中执行join()方法后,线程A就立刻暂停。直到线程B执行结束以后,线程A才开始执行。在这里插入图片描述

后台线程:

后台线程是用来服务其它线程的,比如JVM就是典型的后台线程。后台线程有个特征:所有的前台线程死亡后,后台线程才会自动死亡。

前台线程创建的线程是前台线程,后台线程创建的线程是后台线程。

主线程默认是前台线程,所以我们平时创建的线程都是前台线程。

将一个线程设置为后台线程的方法是通过线程实例的setDaemon(true)方法。判断一个线程是不是后台线程可用isDaemon()方法,返回true则为后台线程,反之为前台线程。

线程一旦进入就绪状态,即执行start()之后,就不能再更改线程属性,即不能条用setDaemon()方法,否则会引起IllegalThreadStateException异常。所以线程的setDaemon()方法必须在start()方法之前执行。

线程睡眠

sleep()方法可让当前正在执行的线程进入阻塞状态。下面是sleep()方法的文档
在这里插入图片描述

要注意到sleep()是静态方法不是实例方法,所以它的功能是让当前正在执行的方法进入阻塞状态

线程让步

yield()和sleep()方法类似,两者都是静态方法,并且都是让现正在运行的线程暂停。不过不同的是,sleep()是让线程进入阻塞状态,而yield()是让线程进入就绪状态。

所以执行yield()后,即使当前执行的线程转入就绪状态,但下一个瞬间它还是有可能被选中继续执行。

改变线程优先级

每个线程具有一定的优先级,优先级高的线程会获得更高的执行机会。

Thread类提供了setPriority(int newPriority)和getPriority()两个实例方法来设置线程的优先级和获取线程的优先级。优先级是int类型,从1到10,数字越大优先级越高。Thread有三个静态变量表示优先级,

  • MAX_PRIORITY:值为10。
  • MIN_PRIORITY:值为1。
  • NORM_PRIORITY:值为5。

当然,不同的操作系统的优先级不一定都是1到10,比如Windows 2000就只提供了7个优先级,所以编程时要避免直接为线程指定数字的优先级,而要用上面三个静态变量确保高移植性。

主线程的优先级默认是5,子线程的优先级默认与父线程的优先级相等,所以平时我们创建的子线程默认优先级为5。

中断线程


3、线程的同步

多线程环境下,一个线程的执行随时会被另一个线程打断。一般情况下这没什么,等会再继续执行呗。但是如果是多个线程同时访问操作一个公共资源,那就很可能出现问题。学过操作系统的都知道,多个线程同时读一个资源完全不会有事,但只要涉及到写,那就会出现隐患,无论是同时写还是同时读写。

同步解决的就是多线程访问操作同一公共资源时可能出现的问题。解决方式是加锁,一个线程想要访问操作一个资源时,就必须先取得关于这个资源的锁才能继续。当线程取得锁,完成相关操作后,才会将锁还回去,其他线程才有机会获得锁。

Java中加锁是通过synchronized和lock,下面一一讲解。

3.1 synchronized

synchronized有两种使用方法,一种是同步代码块,另一钟是同步方法,两者的主要区别是加锁的对象不同。

3.1.1同步代码块

先说说同步代码块,同步代码块的语法是

synchronized(obj){
···
}
其中的obj是线程之间的共享变量,如果这个变量是线程独有的,那加这个锁毫无意义。

这个语法的含义是,在线程执行括号包含的代码之前,必须先获得关于obj这个公共资源的锁后才能执行。而且obj同一时间内只能被一个线程锁住,而且只有这个线程执行完之后才会将锁释放,然后别的线程才能进入这个代码块执行。



3.1.2同步方法

同步方法的语法是在方法(必须是实例方法),代码如下

public synchronized void test(){
    ...
}

只要将synchronized 放在限定词之后,返回值之前就行了。要记住,同步方法是对this加锁,也就是这个方法的调用者,而不是对这个方法加锁。

一个类A有两个方法,一个加锁一个不加,代码如下

class A{
    public synchronized void a(){...}
    public void b(){...}
}

生成两个线程和A的实例对象,线程1执行实例的a方法,线程2执行实例的b方法。因为a方法加上了锁,所以线程1在执行a方法前,要先取得关于实例对象的锁;而方法b没加锁,所以线程2随时可以执行b方法。

于是写了下面的代码

class A{
    public synchronized void a(){
        int i = 0;
        for (; i < 20; i++){
            System.out.println("方法a:" + i);
        }
    }
    public void b(){
        int i = 0;
        for (; i < 100; i++){
            System.out.println("方法b:" + i);
        }
    }
}

public class Test {
    public static void main(String[] args) throws Exception{
        A a = new A();
        FutureTask<Integer> task1 = new FutureTask<>(() -> {
            //对象a的a方法没加锁,即访问a方法需要获得关于a对象的锁
            a.a();
            return;
        });
        FutureTask<Integer> task2 = new FutureTask<>(() -> {
        	//对象a的a方法没加锁,即访问a方法需要获得关于a对象的锁
            a.a();
            return;
        });
        FutureTask<Integer> task3 = new FutureTask<>(() ->{
        	//对象a的b方法没加锁,即访问b方法不需要获得关于a对象的锁
            a.b();
            return;
        });
        //线程1
        new Thread(task1).start();
        //线程2
        new Thread(task2).start();
        //线程3
        new Thread(task3).start();
    }
}

根据上面的分析,线程1和线程2会争夺锁。因为线程1先得到锁,所以线程2必须等到线程1释放锁以后(上面的例子中线程1释放锁就相当于线程1执行完成),才能有机会获得锁。所以预测线程2要等到线程1执行完成之后才会开始执行a方法。

而线程3调用的b方法不需要获得锁,所以线程3会随机执行。

运行上面的代码,执行结果验证了以上的分析。
在这里插入图片描述

可以看到,线程1和线程2是交替执行的。

但是如果把方法b也加上锁

//其余代码不变
public synchronized void b(){
    int i = 0;
    for (; i < 100; i++){
        System.out.println("方法b:" + i);
    }
}
//其余代码不变

那么执行结果就变成了严格的顺序执行,而两个线程访问的是不同方法,所以同步方法是对this加锁,而不是对方法加锁。
在这里插入图片描述

3.1.3锁释放的时间

  • 当线程的同步区的代码执行完自动释放锁。
  • 当同步区的代码出现return或break使程序跳出同步区,则锁自动释放。
  • 当同步区的代码出现为处理的Error或Exception导致同步区代码结束时,锁自动释放。
  • 当同步区代码执行时,出现线程的wait()操作,则线程被暂停并释放锁

要注意,以下的操作并不会释放锁。

  • 程序调用了Thread.sleep()和Thread.yield()使线程暂停时,线程不会释放锁
  • 程序调用suspend()方法时,线程也不会释放锁。

3.2 Lock锁

Lock是一个更强大的同步机制,它的锁样式较多,甚至可允许一定程度的并发操作。synchronized就比较暴力,原本多线程对同一资源只读不写是完全没问题的,但是synchronized的锁不允许这种情况出现,而Lock则可以。

待续·······





4、线程通信

线程的执行虽然是由系统来决定怎么调度,但我们还是有一些权利来影响系统的决策。如何影响就是线程通信。

4.1 传统的线程通信

传统的线程通信是通过Object类提供的wait()、notify()、notifyAll()三个方法。上面提到synchronized有两种使用方法,一种是同步代码块,另一种是同步方法。这两者加锁的对象分别是synchronized括号里的对象和this。我们就调用被锁住的对象的wait()、notify()、notifyAll()三个方法即可实现线程通信。

这三个方法的效果如下

  • wait():使占用该锁的线程等待,等待会让该线程放弃对锁的占用,并且等待(也就是阻塞)不是就绪状态,线程在唤醒之前都不会再次占用锁。其它线程调用该锁的notify()方法或者notifyAll()通知唤醒该线程。该方法有两种重载形式:不带参数,线程无线等待;带参数的,有毫秒和毫微秒级别的参数。
  • notify():唤醒在这个锁上处于等待状态的一个线程。如果有多个线程,那是选择任意一个线程唤醒。
  • nofityAll():唤醒在这个锁上等待的所有线程。

4.2 使用Condition控制线程通信

如果系统不适用synchronized来保证同步,而是用Lock来保证同步,因为系统没有隐式的同步监视器,那我们就不能用传统的方法来控制线程通信。这时候我们就用Condition来控制线程通信。

使用Condition的话,Lock就相当于同步方法或同步方法块,Condition就相当于同步监视器。

Condition实例被绑定在一个Lock对象上,要获得特定的Lock实例的Condition实例,调用Lock对象的newCondition()即可。Condition提供了下面三个方法

  • await():相当于wait()方法,等待的线程直到其它的线程调用该Condition的signal()方法或signalAll()方法才能被唤醒。await()也有多个重载形式,和wait()一样。
  • signal():和notify()方法一样。
  • signalAll():和notifyAll()一样。

4.3 阻塞队列控制线程通信

Java5提供了一个BlockingQueue接口,虽然该接口是Queue的子接口,但它的主要作用并不是作为一个容器,而是一个线程通信的工具。

阻塞队列有这样一个特征:当生产者试着想阻塞队列中放入元素时,如果队列已满,则生产者线程阻塞;当消费者试图从阻塞队列中获取元素时,如果队列为空,则消费者线程阻塞。

放入元素和获取元素分别对应着以下几个方法

  • 在队列尾部插入元素:add(E e),offer(E e)和put(E e)。正常情况下,这三个方法效果相同。但队列已满时,这三个方法分别会抛出异常,返回false,阻塞队列。
  • 在队列头部取出元素:remove(),poll()和take(),正常情况下,这三个方法效果相同。但队列为空时,这是哪个方法分别会抛出异常,返回false,阻塞队列。

BlockingQueue以及其实现类如下图所示
在这里插入图片描述

  • ArrayBlockingQueue:基于数组实现的BlockingQueue队列。
  • LinkedBlockingQueue:基于链表实现的BlockingQueue队列。
  • PriorityBlockingQueue:这并不是个标准的阻塞队列。和PriorityQueue类似,该队列调用remove()、poll()和take()方法来取出元素时,并不是取出队列中存在试驾最长的元素,而是队列中最小的元素。对于大小的判定是根据元素实现的Conparable接口来判断。
  • SynchronousQueue:同步队列。该队列的存取操作必须交替执行。
  • DelayQueue:他是特殊的BlockingQueue,底层基于PriorityBlockingQueue实现。但是DelayQueue要求集合元素都实现Delay接口(接口里有一个long getDelay()方法),DelayQueue根据集合元素的getDelay()方法的返回值进行排序。

4.4 线程组和未处理的异常

Java可以以组为单位来管理线程,使用ThreadGroup。我们直接对ThreadGroup进行操作,就相当于对属于这个组的所有线程进行相同的操作。

我们创建的线程其实都属于某一个线程组,及时我们没有显式指定。默认情况下,子线程属于父线程的组,这个逻辑和设置后台线程的逻辑差不多。

显式设置线程所属的组只能通过线程的构造函数,所以,线程一旦被创建,那线程所属的组就永远不能被改变。下面是唯一的三个为线程指定线程组的方法。

  • Thread(ThreadGroup group, Runnable target):以target的run()方法作为线程执行体创建新线程,属于group线程组。
  • Thread(ThreadGroup group, Runnable target, String name):以target的run()方法作为线程执行体创建新线程,属于group线程组,线程名为name。
  • Thread(ThreadGroup group, String name):创建新线程,线程名为name,属于group线程组。

我们可以通过getThreadGroup()来获取线程所属的线程组。

线程组有两个构造器,ThreadGroup(String name)和ThreadGroup(ThreadGroup parentGroup, String name)。这第一个构造器很容易理解,第二个构造器是创建一个输入parentGroup的子线程组,子线程组的名字为name。

线程组创建后不允许改名,只能通过getName()获取名字。

线程组有以下几个常用方法

  • int activeGroup():返回此线程组中活动线程的数目。
  • interrupt():中断该线程中的所有线程。
  • isDaemo():判断该线程组是否是后台线程组。
  • setDaemon(boolean daemon):把该线程组设置成后台线程组,当后台线程组的最后一个线程执行结束或被销毁后,后台线程自动销毁。
    setMaxPriority(int pri):设置线程组的最高优先级。

猜你喜欢

转载自blog.csdn.net/sinat_38393872/article/details/106267242