十二、Java 多线程

线程概述

在开始 Java 多线程的学习之前,先做一下学习的储备学习,什么是线程?说到线程又不得不提到进程,那进程又是什么?

  1. 进程

当一个程序进入内存运行时,即变成一个进程。进程时处于运行过程中的程序,并具有一定独立功能,**且是系统进行资源分配和调度的一个独立单位。**可以打开任务管理器看一看,加深理解。
在这里插入图片描述

  1. ** 线程**

线程也被称为轻量级进程,线程是进程的执行单元。一个进程可以拥有多个线程,一个线程必须有一个父进程。
线程可拥有自己的堆栈、自己的程序计数器和自己的局部变量,但不拥有系统资源,它与父进程的其他线程共享进程拥有的全部资源。

归纳起来可以那么说,操作系统可执行多个任务,每个任务就是进程,进程可同时执行多个任务,每个任务就是进程。

  1. 多线程的优势

    • 引入线程主要是为了提高系统的执行效率,减少处理机的空转时间和调度切换的时间,以及便于系统管理。
    • 因为进程不能共享内存,但线程之间共享内存就很容易。
    • 系统创建进程需要为该进程重新分配系统资源,但创建线程则代价小很多,因此多线程比多进程的效率高。
    • 多个线程可并发执行。
    • Java 语言提供了内置的多线程功能支持,简化了 Java 多线程编程。
  2. 并行和并发

并行:

  • 并行性是指同一时刻内发生两个或多个事件。
  • 并行是在不同实体上的多个事件

并发:

  • 并发性是指同一时间间隔内发生两个或多个事件。
  • 并发是在同一实体上的多个事件

由此可见:并行是针对进程的,并发是针对线程的

线程的创建和启动

Java 使用 Thread 类代表线程,所有线程对象都必须是 Thread 类或其子类的实例。

继承 Thread 类创建线程类

这种方法创建并启动多线程步骤如下:

  • 定义 Thread 类的子类,并重写该类的 run() 方法,该 run() 方法的方法体代表了线程需要完成的任务,run() 方法也被称为线程执行体。
  • 创建 Thread 子类的实例,即创建了线程对象。
  • 调用线程对象 start() 来启动线程。
public  class FirstThread extends Thread
    {
        private int i ;
        public void run(){
            for( ; i < 100 ; i++)
            {
                //当线程类继承Thread类时,直接使用this可获取当前线程
                //Thread对象的getName()返回当前线程名字
                System.out.println(getName() + " " + i);
            }
        }
        public static void main(String[] args) {
            for(int i = 0;i < 100;i++){
                //调用Thread的currentThread方法获取当前线程
                System.out.println(Thread.currentThread().getName() + " " + i);
            if(i == 20) {
                new FirstThread().start();
                new FirstThread().start();
            }
            }
        }
    }

读者在运行上述代码时,会发现一共会有三个线程,出了两个显式 new 出来的两个线程以外,还有一共主线程 main() ,这是因为** main() 方法的方法体就是主线程的线程执行体。**
还有一个需要注意的是,打印出来的结果,循环变量 i 不是连续的,这表明这些线程中没有共享数据,这也是继承 Thread 类来创建线程类需要注意的地方。

注:默认情况下,主线程名字为 main(),用户启动的多个线程的名字依次为 Thread-0、Thread-1、Thread-2······等。可通过 setName(String name)方法为线程设置名字。

实现 Runnable 接口创建线程类

实现 Runnable 接口创建线程类步骤如下:

  • **①,**定义 Runnable 接口实现类,并重写该接口的 run() 方法,该 run() 方法的方法体同样是该线程的线程执行体。
  • **②,**创建 Runnable 实现类的实例,并以此实例作为 Thread 的 target 来创建 Thread 对象,该 Thread 对象才是真正的线程对象。
//创建 Runnable 实现类对象
SecondThread st = new SecondThread();
//以 Runnable 实现类的对象作为 Thread 的 target 来创建对象,即线程对象
new Thread(st);
//也可以像下面那样为线程指定一个名字
//new Thread(st,"线程1")
  • **③,**调用线程对象的 start() 方法来启动线程

下面展示一下全过程:

public class SecondThread implements Runnable
    {
        private int i ;
        public void run(){
            for( ; i < 100 ; i++)
            {
                //当线程类继承Thread类时,直接使用this可获取当前线程
                //Thread对象的getName()返回当前线程名字
                System.out.println(Thread.currentThread().getName() + " " + i);
            }
        }
        public static void main(String[] args) {
            for(int i = 0;i < 100;i++){
                //调用Thread的currentThread方法获取当前线程
                System.out.println(Thread.currentThread().getName() + " " + i);
            if(i == 20) {
                SecondThread st = new SecondThread();
                new Thread(st,"线程1").start();
                new Thread(st,"线程2").start();
            }
            }
        }
    }

读者在运行上述代码的时候,会发现两个子线程的 i 变量是连续的,也就是说采用该方法创建的多个线程是可以共享线程类的实例变量。

使用 Callable 和 Future 创建线程

从上面的代码看出 Thread 类的作用是把 run() 方法包装成执行体,而 Callable 接口提供了 call() 方法可以作为线程执行体,但 call() 方法比 run() 方法功能更强大。

  • call() 方法可以有返回值
  • call() 方法可以声明抛出异常

Future 接口来代表 Callable 接口中 call() 方法的返回值,并为 Future 接口提供了一个 FutureTask 实现类,该实现类实现了 Future 接口,并实现了 Runnable 接口——可作为 Thread 类的 targe。

创建并启动有返回值的线程步骤如下:

  • **①,**创建 Callable 接口的实现类,并实现 call() 方法,该 call() 方法作为线程执行体,并且拥有返回值,再创建 Callable 实现类的实例。
  • **②,**使用 FutureTask 类来包装 Callable 对象,该 FutureTask 对象封装了该 Callable 对象的 call() 方法的返回值。
  • **③,**使用 FutureTask 对象作为 Thread 对象的 target 创建并启动新线程。
  • **④,**调用 FutureTask 对象的 get() 方法来获得子线程执行结束后的返回值。

来个完整代码:

public class ThirdThread
{
        public static void main(String[] args) {
            //创建 Callable 对象
            ThirdThread rt = new ThirdThread();
            
			//先使用Lambda表达式创建Callable<Integer>对象
            //使用 FutureTask 来包装Callable对象
            FutureTask<Integer> task = new FutureTask<Integer>((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(i == 20)
                {
                    new Thread(task,"有返回值的线程").start();
                }
            }
            try
            {
                //获取线程返回值
                System.out.println("子线程的返回值:" + task.get());
            }catch (Exception ex)
            {
                ex.printStackTrace();
            }
        }
}

上面程序使用 Lambda 表达式直接创建了 Callable 对象,这样就无须创建 Callable 实现类,再创建 Callable 对象了。运行上面的程序,会看到主线程和 call() 方法所代表的线程交替执行的情形,最后会输出 call() 方法的返回值。

创建线程的三种方式对比

Runnable 接口和实现 Callable 接口的方式基本相同,只是 Callable 接口里定义的方法有返回值,可以声明抛出异常而已。所以在下面归为一类去讨论。

  • 采用 Runnable、Callable 接口的方式优缺点:
    • 线程类只是实现了接口,还可以继承其他类。
    • 多个线程可同享同一个 target 对象。
    • 劣势,编程稍微复杂,如果需要访问当前线程,则需使用 Thread.currentThrea()方法。
  • 采用 Thread 类的方式创建多线程的优缺点:
    • 编程简单,如果需要访问当前线程,直接使用 this 即可获得当前线程
    • 劣势,因为线程类已经继承 Thread 类,所以不能再继承其他父类。

线程的生命周期

在这里插入图片描述

当线程被创建后,是有生命周期的,一个线程需要经过新建(new)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、死亡(Dead)五种状态。几乎所有的现代桌面和操作系统都采用抢占式调度策略
对于抢占式调度策略,系统会给每个执行的线程一个小时间段来处理任务,当该时间段用完后,系统会剥夺该线程所占用的资源,让其他线程获得执行的机会。即,一个线程不可能一直占着CPU运行,而是 CPU 在多条线程进行定期不断切换的。

  • 新建状态:当程序使用 new 关键字创建了一个线程后,该线程就处于新建状态,此时由 Java 虚拟机进行内存分配和初始化。
  • 就绪状态:当线程对象调用了 start() 方法之后,该线程处于就绪状态,在这个状态的线程并没有真正开始运行,只是表示该线程可以运行了。
  • 运行状态:如果处于就绪状态的线程获得了 CPU,则开始执行 run() 方法的线程执行体。(一个处理器只能处理一个线程,只是速度非常快)
  • **阻塞状态:**以下情况线程会进行阻塞状态:
    • 线程调用 sleep()方法主动放弃所占用的处理器资源
    • 线程调用了一个阻塞式 IO 方法,在该方法返回前,该线程被阻塞
    • 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有。(后面会介绍)
    • 线程在等待某个通知(notify——用来唤醒线程的方法)
    • 程序调用线程的 suspend() 方法将该线程挂起。这个方法容易死锁,尽量避免使用。
  • 以下是可以解除阻塞的方法:
    • 调用 sleep() 方法的线程经过了指定时间。
    • 线程调用的阻塞式 IO 方法已经返回。
    • 线程成功的获得了试图取得的同步监视器。
    • 线程正在等待某个通知时,等到了通知。
    • 处于挂起的线程被调用了 resume()恢复方法。
  • **死亡状态:**以下三种方法会让线程结束:
    • run() 或 call() 方法执行完成,线程正常结束。
    • 线程抛出一个未捕获的 Exception 或 Error。
    • 直接调用该线程的 stop() 方法来结束该线程——容易导致死锁,不推荐。
      • 线程对象提供 isAlive() 方法,当线程处于非新建、死亡两种状态时返回 true,否则返回 false。
      • 死亡状态的线程不可再次调用 start()方法,否则会引发 IllegalThreadStateException 异常。

控制线程

Java 的线程支持提供一些便捷的工具方法,通过这些方法,可以很好的控制线程的执行。

join 线程

Thread 提供的 join 方法,让一个线程等待另一个线程完成的方法。

//使用实例,必须等jt执行结束后才会向下执行
JoinThread jt = new JoinThread("被Join的线程");
jt.join();

join() 方法如下三种重载形式:

  • join():等待被 join 的线程执行完成。
  • join(long millis):等待被 join 的线程的时间最长为 millis 毫秒,若 millis 毫秒内被 join 的线程还没执行结束,则不再等待。
  • join(long millis,int nanos):等待被 join 的线程的时间最长为 millis 毫秒加 nanos 毫微秒。(一般没必要使用这么高的精度。)

后台线程

有一种线程,是在后台运行的,它的任务是为其他的线程提供服务,这种线程叫做后台线程(Daemon Thread),又被称为“守护线程”和“精灵线程”。JVM 的垃圾回收线程就是典型的后台线程。
后台线程特征:如果所有前台线程都死亡,则后台线程会自动死亡。后台线程创建的子线程默认为后台线程。
调用 Thread 对象的 setDaemon(true) 方法可将指定线程设置成后台线程。
调佣 Thread 独享的 isDaemon()方法,用于判断指定线程是否为后台线程。

注意:将某个线程设置为后台线程,必须在该线程启动之前设置,否则会引发 IllegalThreadStateException 异常。

线程睡眠:sleep

通过调用 Thread 类的静态 sleep() 方法可以实现让当前正在执行的线程暂停一段时间,并进入阻塞状态。
Thread.sleep(1000) //让当前线程暂停1s
在其睡眠时间段内,即使系统中没有其他可执行的线程,处于 sleep()中的线程也不会执行,因此 sleep() 方法常用来暂停程序的执行。

线程让步:yield

yield() 和 sleep() 方法有点相似,它也是 Thread 类提供的一个静态方法,它可以让当前正在执行的线程暂停,但不会阻塞该线程它会将该线程转入就绪状态,yield() 只是让当前线程暂停一下,让系统的线程调度器重新调度一次。
实际上,当某个线程调用了 yield() 方法暂停后,只有优先级与当前线程相同,或者优先级比当前线程更高的且处于就绪状态的线程才会获得执行的机会。

sleep() 方法和 yield() 方法区别

  • sleep() 方法暂停当前线程后,会给其他线程执行机会,不会理会其他线程的优先级;但 yield() 方法只会给优先级相同,或优先级更高的线程执行机会;
  • sleep() 方法会将线程转入阻塞状态,直到经过阻塞时间才会转入就绪状态;而 yield() 不会将线程转入阻塞状态,它只是强制当前线程进入就绪状态,因此完全有可能某个线程调用 yield() 方法暂停之后,立即再次获得处理器资源被执行。
  • sleep() 方法声明抛出了 InterruptedException 异常,所以调用 sleep() 方法时要么捕捉该异常,要么显式声明抛出该异常;而 yield() 方法则没有声明抛出任何异常。
  • sleep() 方法比 yield() 方法有更好的可移植性,通常不建议使用 yield() 方法来控制并发线程的执行。

改变线程优先级

每个线程执行时都具有一定的优先级,优先级高的线程获得较多的执行机会,而优先级低的线程则获得较少的执行机会。
每个线程默认的优先级都与创建它的父线程的优先级相同。

Thread 线程提供了 getPriority(int newPriority)、getPriority()方法来设置和返回指定线程的优先级,setPriority()方法的参数可以是一个整数,范围是1~10之间,一般我们使用 Thread 类的三个静态常量,这样可以保证程序具有较好的可移植性(不同操作系统上的优先级并不相同)。

  • MAX_PRIORITY:值是10
  • MIN_PRIORITY:值是1
  • NORM_PRIORITY:值是5

线程同步

多线程很容易突然出现“错误情况”,这是由于系统的线程调度具有一定的随机性造成的。那么怎么解决呢?

同步代码块

Java 的多线程引入了同步监视器,使用同步监视器的通用方法就是同步代码块。而同步监视器的目的是,阻止两个线程对同一个共享资源进行并发访问,因此通常推荐使用可能被并发访问的共享资源充当同步监视器。
语法如下:

synchronized(obj)
{
    ···
    //此处的代码就是就是同步代码块
}

上面的语法中,obj就是同步监视器。上面代码的含义是:线程开始执行同步代码块之前,必须先获得对同步监视器的锁定。
任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完成后,该线程会释放对该同步监视器的锁定。
这样的做法符合“加锁——修改——释放锁”的逻辑。任何线程在修改指定资源前,首先对该资源加锁,在加锁期间其他线程无法修改该资源,当该线程修改完成后,该线程释放对该资源的锁定。通过这种方式可以保证并发线程在任一时刻只有一个线程可以进入修改共享资源区的代码,从而保证了线程的安全性。

同步方法

与同步代码块对应的,Java 的多线程安全支持还提供了**同步方法——使用 synchronized 关键字来修饰的方法。**对于同步方法来说无须指定同步监视器,同步方法的监视器就是调用该方法的对象,this。

同步监视器的锁定

任何线程在进去同步代码块、同步方法前,都要先获得对同步监视器的锁定,那么何时会释放对同步监视器的锁定呢?而程序无法显式释放对同步监视器的锁定。以下情况会释放对同步监视器的锁定。

  • 当线程同步代码块、同步方法执行结束时
  • 当线程在同步代码块、同步方法中遇到 break、return 终止了该代码块、该方法的继续执行时
  • 当线程在同步同步代码块、同步方法中出现了未处理的 Error 或 Exception,导致了该同步代码块、同步方法异常结束时
  • 当前线程执行同步代码块、同步方法时,程序执行了同步监视器对象的 wait()方法时

还有一些情况可能会被误认为会释放同步监视器:

  • 线程执行同步代码块或同步方法时,程序调用 Thread.sleep()、Thread.yield() 方法来暂停当前线程的执行,不会进行同步监视器的释放。
  • 线程执行同步代码块时,其他线程调用了该线程的 suspend() 方法将线程挂起,该线程不会释放同步监视器。当然,程序应该尽量避免使用 suspend() 和 resume() 方法来控制线程。

同步锁

Java 5 开始提供了一种功能更强大的**线程同步机制——通过显式定义同步锁对象来实现同步。**同步锁由 Lock 对象充当。Lock 提供了比关键字 synchronized 修饰的代码块和方法有更广泛的锁定操作,并且支持多个相关的 Condition 对象。
Lock 是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock 对象加锁,线程开始访问共享资源前应先获得 Lock 对象。
某些锁可能允许对共享资源并发访问,如 ReadWriteLock(读写锁),它和 Lock 是 Java 5 提供的根接口,并为 Lock 提供了 ReentrantLock(可重入锁)实现类,为 ReadWriteLock 提供了 ReentrantReadWriteLock 实现类。

重入性:一个线程可以对已被加锁的 ReentrantLock 锁再次加锁。

在实现线程安全的控制中,比较常用的是 ReentrantLock(可重入锁),使用代码格式如下:

class X
{
    //定义锁对象
    private final ReentrantLock lock = new ReentrantLock();
    ···
    //定义需要保证线程安全的方法
    public void m()
    {
        //加锁
        lock.lock();
        try
        {
            //需要保证线程安全的代码
            //···方法体
        }
        //使用finally块来保证释放锁
        finally
        {
            lock.unlock();
        }
    }
}

死锁

**当两个线程相互等待对方释放同步监视器时就会发生死锁。一旦出现死锁程序既不会发生异常也不会给出任何提示,只是所有线程都处于阻塞状态,无法继续。**在编程中应该避免死锁的出现。

线程通信

当线程在系统内运行时,线程的调度具有一定的透明性,程序通常无法准确控制线程的轮换执行,但 Java 也提供了一些机制来保证线程协调运行。

传统的线程通信

关键字 synchronized 修饰的同步代码块,需要使用括号里的对象调用下面三个方法;
关键字 synchronized 修饰的同步方法,因为该类的默认实例(this)就是同步监视器,所以可以在同步方法直接调用如下这三个方法。

  • wait():导致当前线程等待
  • notify():唤醒在此同步监视器上等待 单个线程,所唤醒的线程是随机的。
  • notifyAll():唤醒在此同步监视器上等待的所有线程。

使用 Condition 控制线程通信

如果是使用 Lock 对象来保证同步,则系统中不存在隐式的同步监视器,也就不能使用 wait()、notify()、notifyAll方法进行线程通信。所以当使用 Lock 对象来保证同步时,Java 提供了 Condition 类来保持协调,使用 Condition 可以让那些已经得到 Lock 对象却无法继续执行的线程释放 Lock 对象, Condition 对象也可以唤醒其他处于等待的线程。

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

  • await():类似于隐式同步监视器上的 wait() 方法,导致当前线程等待
  • signal():唤醒在此 Lock 对象上等待的单个线程
  • signalAll():唤醒在此 Lock 对象上等待的所有线程

和上面传统的线程通信来对比看,方法作用几乎是一样的,只不过一个是用于隐式同步监视器,一个用于 Lock 对象的。

使用阻塞队列(BlockingQueue)控制线程通信

BlockingQueue 是 Java 5 提供的接口,也是 Queue 的子接口,而它的作用不是用作容器,是用作线程同步的工具。

BlockingQueue 具有一个特征:当生产者线程试图从 BlockingQueue 中放元素时,如果该队列已满,则线程被阻塞,当消费者试图从中取出元素时,如果该队列已空,则该线程被阻塞。

BlockingQueue 提供如下两个支持阻塞的方法:

  • put(E e):尝试把 E 元素放入 BlockingQueue 中,如果该队列元素已满,则阻塞该线程。
  • take():尝试从 BlockingQueue 头部取出元素,如果该队列的元素已空,则阻塞该线程。

BlockingQueue 继承了 Queue 接口,理所当然的可以使用 Queue 接口中的方法,如下:

  • 在队列尾部插入元素,包括 add(E e)、offer(E e) 和 put(E e) 方法。当该队列已满时,这三个方法分别会抛出异常、返回 false、阻塞队列。
  • 在队列头部删除并返回删除的元素,包括 remove()、poll() 和 take() 方法。当该队列已空时,这三个方法分别会抛出异常、返回 false、阻塞队列。
  • 在队列头部取出但不删除元素,包括 element() 和 peek() 方法。当队列已空时,这两个方法分别抛出异常、返回 false。

线程池

系统启动一个新线程的成本是比较高的,因为它涉及与操作系统交互。这种情况下,使用线程池可以很好的提高性能,尤其是当程序中需要创建大量生存期很短暂的线程时,更应该考虑使用线程池。

线程池在系统启动时即创建大量空闲的线程,程序将一个 Runnable 对象或 Callable 对象传给线程池,线程池就会启动一个线程来执行它们的 run() 或 call() 方法;当 run() 或 call() 方法执行结束后,该线程并不会死亡,而是再次返回线程池中称为空闲的状态,等待执行下一个 Runnable 对象的 run() 或 call() 方法。使用线程池可以有效控制系统中并发线程的数量,当系统中包含大量并发线程时,会导致系统性能剧烈下降,甚至导致 JVM 崩溃,而线程最大线程参数可以控制系统中并发线程数不超过此数。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值