Java多线程学习笔记

169 阅读16分钟

1. 什么是多线程?

线程是指进程中的实际运行单位,它是进程中一个最小运行单元,而多线程就是指一个进程中同时有多个线程在执行,即实现多个线程并发执行的技术,多线程的好处是提高执行效率但是会容易造成死锁的情况。

2. 线程与进程的区别?

进程是指一段正在执行的程序

线程有时也被称为轻量级进程,它是程序执行的最小单元,一个进程可以拥有多个线程,各个线程之间共享程序的内存空间(代码段、数据段、堆空间)及一些进程级的文件(列如:打开的文件),但是各个线程拥有自己的栈空间。在操作系统级别上,程序的执行都是以进程为单位的,而每个进程中通常都会有多个线程互不影响地并发执行。

3. 并发与并行

并发:

同一个CPU执行多个任务,按细分的时间片交替执行

并行:

在多个CPU上同时处理多个任务

4. 为什么要使用多线程?

之前学习的程序在没有跳转语句的前提下,都是由上至下依次执行,那现在想要设计一个程序,边打游戏边听歌,怎么设计?

要解决上述问题,就需要使用多进程或者多线程来解决.

【1】提高执行效率,减少程序的响应时间。因为单线程执行的过程只有一个有效的操作序列,如果某个操作很耗时(或等待网络响应),此时程序就不会响应鼠标和键盘等操作,如果使用多线程,就可以将耗时的线程分配到一个单独的线程上执行,从而使程序具备更好的交互性。 【2】与进程相比,线程的创建和切换开销更小。因开启一个新的进程需要分配独立的地址空间,建立许多数据结构来维护代码块等信息,而运行于同一个进程内的线程共享代码段、数据段、线程的启动和切换的开销比进程要少很多。同时多线程在数据共享方面效率非常高。 【3】目前市场上服务器配置大多数都是多CPU或多核计算机等,它们本身而言就具有执行多线程的能力,如果使用单个线程,就无法重复利用计算机资源,造成资源浪费。因此在多CPU计算机上使用多线程能提高CPU的利用率。 【4】利用多线程能简化程序程序的结构,是程序便于理解和维护。一个非常复杂的进程可以分成多个线程来执行。

5. 线程的创建

5.1 继承Thread

步骤:

①继承Thread

②重写run()方法

在测试类中:

①创建继承了Thread类的子类对象

②调用start()方法

5.2 实现Runnable接口

步骤:

①实现接口

②实现接口中的抽象方法:run()

③创建实现类的对象,不是Thread对象!!! 要注意!

④把此对象作为参数传入到Thread类的构造器中,创建Thread类的对象。

⑤通过Thread对象调用start()方法

5.3 实现Callable接口

Thread构造方法需要传递一个Runnable实例。

因为FutureTask(RunnableFuture的实现类)实现了Runnable接口,所以通过FutureTask现实线程的创建。

即new Thread(new Runnable()).start()和new Thread(new FutureTask()).start()是等价的。

然后它的构造方法如下:

image.png 代码实现:

    //1.创建一个实现了Callable接口的类
public class MyCallable implements Callable<String> {
    //注意泛型,传入的类型就是call方法的返回值类型
    //2.实现类去实现抽象方法call(),将此线程需要执行的操作放在里面
    public String call() throws Exception {
        for (int i=0; i<10; i++){
            System.out.println("MyCallable正在执行:"+new Date().getTime());
        }
        return "MyCallable执行完毕!";
    }
}
public class ThreadCreateDemo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //3.创建实现类的对象
        //4.创建FutureTask类对象,将实现类对象传入构造器中
        FutureTask task = new FutureTask(new MyCallable());
        
        //5.将FutureTask对象放入Thread类构造器中,创建Thread类对象,调用start()
        Thread thread = new Thread(task);//传入FutureTask对象
        thread.start();
​
        for (int i=0; i<10; i++){
            System.out.println("main主线程正在执行:"+new Date().getTime());
        }
        //6.获取Callable中call方法的返回值,get()返回值即为Future Task构造器参数Callable实现类重写的call()的返回值。
        System.out.println(task.get());
    }
}

总结:

最后都是通过Thread实例来调用start()方法。

5.4 线程池

多线程的缺点:

①处理任务的线程的创建和销毁非常耗时和消耗资源

②多线程之间的切换非常耗时和消耗资源

线程池的出现解决了上述的问题:

①使用的线程已存在,消除了创建线程的时耗

②可以通过设置线程的数目,消除资源不足的问题

  • 明确一点:

    本质上来说创建线程的方式就是继承Thread,就算是线程池,内部也是创建好线程对象(使用Thread类的对象,例如

    new Thread.start(task)方法)来执行任务。

创建线程池的四种方式--常用方法--生命周期

线程创建的四种方式及其生命周期

详解Thread

5.5 四种创建线程的区别

Runnable接口和Callable接口的区别:

(1) Runnable接口中的run()方法的返回值是void,它做的事情只是纯粹地去执行run()方法中的代码而已;Callable接口中的唯一抽象方法call()方法有返回值;

(2) Runnable接口的run()方法没有受检异常的异常声明,即异常只能在内部捕获,不能继续上抛, Callable接口的call()方法声明了受检异常,可直接抛出Exception异常,并且可以不予捕获;

(3) Callable实例不能和Runnable实例一样,直接作为Thread线程实例的target来使用;

(4) 异步执行任务在大多数情况下是通过线程池去提交的,而Runnable接口和Callable接口都可以应用于线程池

(5) Callable接口支持返回执行结果,此时需要调用FutureTask.get()方法实现,此方法会阻塞主线程直到获取‘将来’结果;当不调用此方法时,主线程不会阻塞!

Callable接口中的call()方法是有返回值的,是一个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。这其实是很有用的一个特性,因为多线程相比单线程更难、更复杂的一个重要原因就是因为多线程充满着未知性,某条线程是否执行了?某条线程执行了多久?某条线程执行的时候我们期望的数据是否已经赋值完毕?无法得知,我们能做的只是等待这条多线程的任务执行完毕而已。而Callable+Future/FutureTask却可以获取多线程运行的结果,可以在等待时间太长没获取到需要的数据的情况下取消该线程的任务,真的是非常有用。

注意:异步执行任务在大多数情况下是通过线程池去提交的,而很少通过创建一个新的线程去提交(即通过Thread类的方式执行start()方法)。

Runnable接口和Thread之间的联系

public class Thread extends Object implements Runnable 

由此可见:Thread类也是Runnable接口的子类

总结: 实现Runnable接口比继承Thread类所具有的优势:

  1. 适合多个相同的程序代码的线程去共享同一个资源。
  2. 可以避免java中的单继承的局限性。
  3. 增加程序的健壮性,实现解耦操作,代码可以被多个线程共享,代码和线程独立。
  4. 线程池只能放入实现Runable或Callable类线程,不能直接放入继承Thread的类。

其它

在程序开发中只要是多线程肯定永远以实现Runnable接口为主,因为实现Runnable接口相比继承Thread类有如下好处:

  • 避免点继承的局限,一个类可以实现多个接口
  • 资源共享:如果一个类继承Thread,则不适合资源共享。但是如果实现了Runable接口的话,则很容易的实现资源共享。

Runnable和Callable的区别和用法

彻底理解Runnable和Thread的区别

6. 线程安全的理解

当多个线程访问一个对象时,如果不用进行额外的同步控制或其他的协调操作,调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的。

线程不安全构成的三要素:

  • 多线程环境
  • 访问同一个资源
  • 资源具有状态性

什么时候需要考虑线程安全?

1,多个线程访问同一个资源

2,资源是有状态的,比如我们上述讲的字符串拼接,这个时候数据是会有变化的

6.1 线程同步的方法

为了保证每个线程都能正常执行原子操作,Java引入了线程同步机制。

  1. 同步方法
  2. 同步代码块
  3. 锁机制(Lock锁)

7. 线程的状态

线程的声明周期:

在API中 java.lang.Thread.State 这个枚举中给出了六种线程状态:

  1. NEW(新建):新创建的线程,还未启动,即没有调用start()方法。
  2. Runnable(可运行):线程在Java虚拟机中是运行状态,但是具体有没有运行,取决于操作系统处理器。
  3. Blocked(锁阻塞):当线程尝试获取锁的时候,该锁已被其他线程获取,该线程就会进入Blocked状态;当线程持有锁的时候,该线程将变成Runnable状态。
  4. Waiting(无限等待):一个线程在等待另一个线程执行一个(唤醒)动作时,该线程进入Waiting状态。进入这个状态后是不能自动唤醒的,必须等待另一个线程调用notify或者notifyAll方法才能够唤醒。
  5. TimeWaiting(计时等待):同waiting状态,有几个方法有超时参数,调用他们将进入Timed Waiting状态。这一状态将一直保持到超时期满或者接收到唤醒通知。带有超时参数的常用方法有Thread.sleepObject.wait。
  6. Teminated(被终止):因为run方法正常退出而死亡,或者因为没有捕获的异常终止了run方法而死亡。

8. 线程常用的方法

sleep

执行sleep()方法后,线程进入阻塞状态,不会释放“锁标志”,也就是说如果有synchronized同步块,其他线程仍然不能访问共享数据

wait、notify、notifyall

这三个方法用于协调多个线程对共享数据的存取,所以必须在Synchronized语句块内使用这三个方法。前面说过Synchronized 这个关键字用于保护共享数据,阻止其他线程对共享数据的存取。但是这样程序的流程就很不灵活了,如何才能在当前线程还没退出Synchronized数据块时让其他线程也有机会访问共享数据呢?此时就用这三个方法来灵活控制。

wait()方法使当前线程暂停执行并释放对象锁标志,让其他线程可以进入Synchronized数据块,当前线程被放入对象等待池中。当调用 notify()方法后,将从对象的等待池中移走一个任意的线程并放到锁标志等待池中,只有

锁标志等待池中的线程能够获取锁标志;如果锁标志等待池中没有线程,则notify()不起作用。

notifyAll()则从对象等待池中移走所有等待那个对象的线程并放到锁标志等待池中。

join

在某些情况下,子线程需要进行大量的耗时运算,主线程可能会在子线程执行结束之前结束,但是如果主线程又需要用到子线程的结果,换句话说,就是主线程需要在子线程执行之后再结束。这就需要用到join()方法,调用该方法后线程进入阻塞状态。

yield

yield()方法和sleep()方法类似,也不会释放“锁标志”,区别在于,它没有参数,即yield()方法只是使当前线程重新回到可执行状态,所以执行yield()的线程有可能在进入到可执行状态后马上又被执行,因为该线程还会进行cpu资源的争夺,另外yield()方法只能使同优先级或者高优先级的线程得到执行机会,这也和sleep()方法不同。调用该方法线程进入就绪状态。

interrupt

首先,一个线程不应该由其他线程来强制中断或停止,而是应该由线程自己自行停止。所以,Thread.stop, Thread.suspend, Thread.resume 都已经被废弃了。而 Thread.interrupt 的作用其实也不是中断线程,而是「通知线程应该中断了」,具体到底中断还是继续运行,应该由被通知的线程自己处理。 interrupt()方法并不会真的中断线程,当调用该方法时:

①正常运行任务时,经常检查中断标志位,如果被设置了中断标志就自行停止线程。

②调用阻塞方法时,正确处理InterruptException异常。(例如:catch异常后就结束线程)

suspend

suspend()方法就是将一个线程挂起(暂停),resume()方法就是将一个挂起线程复活继续执行。

start、run

start方法是启动线程,run方法是线程所需执行的操作。

9. 线程的等待唤醒

9.1 概念

线程间的通信: 线程处理同一份资源,但是线程的任务是不一样的。

为什么要处理线程间通信: 需多个线程完成同一件任务时,并希望多个线程有规律的执行,那么多个线程间需要协调通信,以此达到共同操作同一份数据的目的。

如何保证线程间通信有效利用资源: 使用等待唤醒机制,避免多个线程操作同一份数据时,对同一共享变量的争夺。

9.2 等待唤醒机制

等待唤醒机制: 线程之间的协作机制,wait/notify就是线程间的一种协作机制。

解决线程间通信问题的三个方法:

  1. wait:这时线程处于waiting状态,线程不在参与活动,因此不会浪费CPU资源,也不会去争夺锁。别的线程执行“通知(notify)”操作,在这个对象上等待的线程才会重新进入调度列表。wait方法只能在同步方法中或同步代码块中使用,而且必须是内建锁。wait方法调用后立即释放对象锁。
  2. notify:唤醒处于等待状态的线程,notify()也必须在同步方法或同步代码块中调用,用来唤醒等待该对象的其他线程。如果有多个线程在等待,随机挑选一个线程唤醒(唤醒哪个线程由JDK版本决定)。notify方法调用后,当前线程不会立刻释放对象锁,要等到当前线程执行完毕后再释放锁。
  3. notifyall:释放所通知对象的所有线程。

注意:

  1. 哪怕只通知了一个等待的线程,被通知线程也不能立即恢复执行,因为它当初中断的地方是在同步块内,而此刻它已经不持有锁,所以它需要再次尝试去获取锁(很可能面临其它线程的竞争),成功后才能在当初调 用 wait 方法之后的地方恢复执行。
  2. wait方法与notify方法需要同一个锁对象调用,因为notify方法唤醒的是同一个锁对象调用的wait方法后的线程。
  3. wait/notify方法都属于Object类,因为锁对象可以是任意对象,而对象都继承Object类。
  4. wait方法和notify方法必须在同步代码块或同步函数中使用,因为必须通过锁对象调用这两个方法。

9.3 为什么wait必须写在同步代码块中?

为了避免CPU切换到其他进程,因为当我们调用wait方法的时候,而其他进程提前执行了notify方法,这样就达不到我们先wait在调用其他方法唤醒的期望了,所以需要一个同步锁保护。

10. 死锁

1.线程调用sleep()方法。立刻交出CPU,但不释放锁

2.线程调用阻塞式IO(BIO)方法

3.线程获取锁失败进入阻塞状态

4.线程调用wait()方法

5.线程调用suspend()方法,将线程挂起,此方法容易导致死锁

每个锁对象都有2个队列。一个称为同步队列,存储获取锁失败的线程。另一个称为等待队列,存储调用wait()等待的线程。将线程唤醒实际上是将处于等待队列的线程移到同步队列中竞争锁。

11. 线程池

线程池的核心知识就是:三大方法、7个参数、拒绝策略、优化配置

11.1 为什么要使用线程池

  1. 提高程序运行效率
  2. 控制线程数量,防止程序崩溃

为了减少创建和销毁线程的次数,让每个线程可以多次使用,可根据系统情况调整执行的线程数量,防止消耗过多内存,所以我们可以使用线程池。

线程池是池化技术演进而来的,简单的说,池化技术就是提前准备好的一些资源以供使用,为的是高效的使用资源。因为线程的创建和销毁,以及数据库的连接和断开都十分浪费资源。

11.2 三大方法

Executor介绍

11.3 7个参数

JUC并发编程学习(十 一)-ThreadPoolExecutor线程池的学习

11.4 四种拒绝策略

AbortPolicy (默认的:队列满了,就丢弃任务(不处理这个人的)抛出异常! ); CallerRunsPolicy(哪来的回哪去? 谁叫你来的,你就去哪里处理); DiscardOldestPolicy (尝试去和最早的竞争,不会抛出异常); DiscardPolicy (队列满了,任务也会丢弃(不处理这个人的),不抛出异常)。

11.5 优化配置

工作中我们可以按照两个方面合理设置线程池的参数:

CPU密集型

根据最大能支持多少个线程同时运行,把线程池的maximumPoolSize(最大线程池)参数设置与CPU处理器一样大就可以。

IO密集型

最大线程数应该设置为IO任务数,对于大文件的读写非常耗时,我们应该用单独的线程慢慢跑。