MENU

Java-50 多线程

October 31, 2023 • Read: 67 • Java阅读设置

多线程

关于线程的基本概念

1 程序、进程和线程

程序是指能够完成某项任务、可以被执行的一段指令,通常强调为是一段静态代码。

进程是指运行的程序,强调动态性。因此,进程具有产生、存在和消亡的状态,即具有其生命周期。

线程是指正在运行的一段可执行路径,也就是说一个程序可能具有多条可执行路径。一个进程可以细分为线程,即一个进程是由线程构成的。如果一个进程由多个线程构成,也就是说一个进程的发生实际上是并行执行多个线程,称该进程是支持多线程的。
例如,一个Java进程就是支持多线程的,其由3个线程构成:1)main方法 2)垃圾回收机制 3)异常处理机制。也就是说,执行一个Java程序,产生一个对应的Java进程,实际上并行运行着3段可执行路径,分别对应main方法、垃圾回收机制和异常处理机制。

进程是资源分配的基本单位。系统运行时会为每个进程分配不同的内存区域。
线程是调度和执行的基本单位,因此,一个进程的多个线程是共享该进程的资源的。但每个线程具有自己的运行栈和程序计数器。

2023-10-31T07:53:13.png
具体地,一个进程中的多个线程共享哪些内存资源,根据完整的JVM内存结构来说明:

  • 每个线程具有自己的虚拟机栈和程序计数器;
  • 一个进程的多个线程共享进程的方法区和堆。

但多个线程操作共享的资源存在安全隐患,这就涉及线程安全。

相比于进程,线程切换的开销更小,因为每个进程的执行环境都不同,而同一个进程的多个线程的执行环境是相同的。

2 单核cpu和多核cpu

单核cpu是指,cpu每次只能调度和执行一个单位(线程),即无法同时调度和执行多个单位(线程)。

多核cpu是指,cpu每次能够调度和执行多个单位(线程),即可以同时调度和执行多个单位(线程),相当于有“多个”cpu一样。

以上2种执行方式分别对应于并发和并行:

  • 并发是指,“同时”执行多个线程,但是通过在同一时间段内,给每个线程轮流分配一个时间片实现。本质上,仍是一次执行一个线程。
  • 并行是指,真正地同时执行多个线程。

3 多线程的优点和使用场景

  • 多线程的优点:
    2023-10-31T08:09:35.png
  • 多线程的使用场景:
    2023-10-31T08:10:13.png

线程的创建

1 线程的创建方式之一:继承Thread类

JVM允许一个应用有多个同时运行的线程,即支持多线程。

Java中的线程通过java.lang.Thread类实现。
2023-10-31T10:11:38.png

一个Thread类对象就是Java中一个程序的可执行线程。
一个线程对应的可执行代码对应于其对应对象的run()方法。
一个线程(即运行的可执行线程)通过调用该线程对象的start()方法实现,即start()方法能够启动对应线程,并运行其对应的run()方法。

继承Thread类的创建线程的方式:
1) 声明一个Thread类的子类;
2) 该子类重写Thread类中的run()方法,即定义通过该子类创建的线程对象的可执行代码;
3) 在要创建线程的程序中,或者说一个程序的主线程中实例化该子类,相当于创建一个线程对象;
4) 调用该线程对象的start()方法。

注意,一个线程对象对应于一个线程,即一个线程对象的start()方法只能调用一次。
可以这样理解,一个线程的实现包括2个部分:

  • 线程对象:创建一个可执行线程;
  • 启动并运行可执行线程:start()方法。

这2个部分是一体的,即这2部分才能实现一个线程。可以理解为:线程对象为线程开辟其运行栈和程序计数器等资源,start()方法用于使该线程处于可执行状态,并执行。

Thread类中的start()方法的功能:

  • 启动可执行线程;
  • 调用可执行线程的run()方法。
    可以理解为,先为其运行初始化环境,然后运行该线程对应的可执行代码。
    start()方法的效果是2个线程同时运行,即主线程和该start()方法对应的线程。
    2023-10-31T10:19:32.png

如果只是调用一个线程对象的run()方法,只代表主线程调用了一个方法的代码,此时仍是只有主线程一个线程,并不代表实现了一个线程。

通过该创建方式实现线程的示意图:
2023-10-31T10:20:53.png

示例:
2023-10-31T10:21:48.png
2023-10-31T10:22:32.png

应用时的注意点:
如果创建的线程所完成的功能只在这里用一次,即一次性的,可以考虑使用匿名子类的方式来创建这样的线程。

  • 没有使用匿名子类的方式创建线程:
    2023-11-01T03:55:40.png
  • 使用匿名子类的方式创建线程:
    2023-11-01T03:57:47.png

2 线程的创建方式之二:实现Runnable接口

1) 什么是Runnable接口
2023-11-01T10:38:48.png

Java中的线程本质上就是必须具备2个特性的对象:

  • 可以被启动,即可以被start
  • 具备一段可执行的代码,且该代码具有特性:对应对象被启动时,该代码希望被执行。
    具备这种可执行代码的对象,称为具备active特性的对象。

Runnable接口的实现类是具备active特性的类,对应对象即具备active特性的对象。

换句话说,Runnable接口的实现类才能具备被启动时希望被执行的可执行代码,而这段代码就是Runnable接口中的run()方法对应的代码。

Java线程的第2个特性可直接理解为:具备Runnable接口的重写run()方法。

2) 实现Runnable接口的线程创建方式
由于Java的线程具备被start的特性,而只有Thread的对象具备该特性,因此,Java的线程只能是Thread类的对象。

由于Thread类本身是Runnable接口的实现类,即Thread类的直接对象具备Runnable接口的重写run()方法。
2023-11-01T10:38:29.png

但是,我们创建的线程往往希望其完成某个功能,而Thread类的所有直接对象(即线程)的run()方法都是Thread类中的run()方法,即相同且固定的,另外查看Thread类的run()方法源码可知,直接通过空参构造器得到的Thread直接对象的run()方法相当于为空,即不存在可执行代码。
因此,通过空参构造器得到的Thread对象即线程,是无实际意义的线程。
2023-11-01T10:41:29.png

Thread类的Runnable接口的重写run()方法可知,线程的创建就是创建,具备有意义的Runnable接口的重写run()方法的Thread对象。
其中一种方式即为前面介绍的继承Thread的方式,通过继承的方式来重写run()方法,使得创建的线程具有有意义的可执行代码,且属于Thread类对象。

根据Thread类的run()方法源码可知,另一种方式即为实现Runnable接口的方式:

  • 定义一个Runnable接口的实现类;
  • 该实现类重写run()方法;
  • 实例化该实现类;
  • 将该实现类的对象作为Thread类构造器的参数,实例化一个Thread对象。

实现Runnable接口的创建线程的方式,本质上就是创建了具备有意义的Runnable重写的run()方法的对象,利用该对象结合Thread实例化来创建线程,可以形象地理解这种创建方式:

  • Runnable实现类对象:提供有意义的Runnable接口的重写run()方法;
  • Thread实例化:提供能被start的特性。

这种创建线程的方式的特点是,可以利用Runnable接口的实现类的一个对象来创建多个对应的线程,这些线程共享该对象的各结构。

3) 示例
2023-11-01T10:57:53.png
2023-11-01T10:58:16.png

3 继承Thread类 vs 实现Runnable接口

开发中,推荐使用实现Runnable接口的方式

  • 实现的方式没有单继承性的局限;
  • 实现的方式更适合于多个线程共享数据的场景。

另外,在官方文档对Runnable接口的介绍中,也提到了使用实现Runnable接口的创建线程方式的适合场景:
如果你打算创建的线程只是具有一个重写的run()方法,而没有其他相关的线程方法,应该使用实现Runnable接口的方式。因为子类存在的更大的意义在于扩展父类的功能。
2023-11-02T03:48:11.png

4 线程的创建方式之三:实现Callable接口

线程就是要具备:1)start()方法;2)重写的Runnable接口的run()方法。
实现Callable接口的线程创建方式,本质上也是提供一个具有重写run()方法的Runnable对象,以作为Thread构造器参数,实例化Thread,以创建线程。

这里并不是像实现Runnable接口创建线程方式那样直接创建一个Runnable实现类,直接得到具有重写run()方法的Runnable对象,而是创建一个FutureTask对象来作为具有重写run()方法的Runnable对象。

2023-11-04T08:47:08.png

FutureTask类是指java.util.concurrent.FutureTask类,是Runnable接口的实现类,因此其对象属于Runnable对象。
但这意味着其所有对象的重写run()方法都是一样的,都是FutureTask类中的定义的重写的run()方法。如果创建的所有线程执行的功能都一样,那这种创建线程的方式没有意义。

FutureTask类的重写run()方法的对应代码实质上是调用Callable对象的call()方法,并且FutureTask构造器接收Callable对象来实例化,该Callable对象对应于其run()方法中调用的Callable对象。
2023-11-04T08:52:48.png
2023-11-04T08:52:10.png
这样,通过不同call()方法的Callable对象构造的FutureTask对象实质上就是具有不同重写run()方法的Runnable对象。

Callable接口是指java.util.concurrent中的Callable接口,其具有一个能抛出异常且具有返回值(返回值类型为Object)的抽象方法call()
2023-11-04T08:54:23.png
2023-11-04T08:54:42.png

因此,通过实现Callable接口的方式创建的线程可以有运行的返回值,且能throws异常。
由于run()方法是没有返回值的,因此线程本质上是没有返回值的,这里只是其对应的run()方法中调用的call()方法具有返回值,该返回值可以通过FutureTaskget()方法来获取。
换句话说,FutureTaskget()方法能够获取其run()方法中调用的call()方法的返回值。
2023-11-04T08:57:15.png
2023-11-04T08:57:28.png

通过实现Callable接口创建线程的一般流程:

  • 定义Callable接口的实现类,重写其call()方法,作为线程的可执行代码;
  • 实例化该Callable接口的实现类对象;
  • 将该Callbel接口的实现类对象作为参数,实例化FutureTask类对象,该对象作为具有重写run()方法的Runnable对象;
  • 将该FutureTask类对象作为参数,实例化Thread类对象,创建线程。

实现Callable接口创建线程方式的优势:

  • 线程的可执行代码可以有返回值;
  • 线程的可执行代码可以throws异常;
  • (call()方法)支持泛型,泛型具体内容后续会学习。

示例:
2023-11-04T09:01:19.png
2023-11-04T09:01:37.png

5 线程的创建方式之四:线程池

线程池可以形象地理解为装有线程的池子,线程池中具有提前创建好的线程,并且这些线程并没有可执行代码。使用线程池时,只需要提供对应的可执行代码,就可以生成一个线程(运行的),并且该线程执行结束后会回收回线程池,即线程池中所有线程可重复利用。

在实际场景中,通过使用线程池来得到线程是更适合的选择。因为在实际场景中,一个项目虽然完整的功能需要大量线程,但面向用户时,并不是一次性展示完所有功能,往往是随用户的行为来展现对应的功能,而实际中每次需要展现给用户的功能是有限个的,并且当用户要求其他功能时,之前展示的功能就不需要了(联想某软件每次展示的图片文字,随着下滑,之前展示的就不需要了)。因此,实际场景下提供有限个线程来重复利用是合适的选择。

使用线程池的好处:
2023-11-04T10:38:07.png

线程池的创建用到的是工具类Executors
2023-11-04T10:39:04.png

主要用到以下方法来创建不同类型的线程池:

  • public static ExecutorService newCachedThreadPool():创建一个可根据需要创建新线程的线程池。
  • public static ExecutorService newFixedThreadPool(int nThreads):创建一个有固定数量线程的线程池。
  • public static ExecutorService newSingleThreadExecutor():创建一个只有一个线程的线程池。
  • public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize):创建一个周期性线程池,可以延时启动、定时启动等。

上述创建线程池的方法,前三个都是返回ExecutorService的对象,ExecutorService实际上是一个接口,其对应的实现类有ThreadPoolExecutor,实际上这些方法返回的对象的类型是ThreadPoolExecutor
2023-11-04T10:43:21.png

而最后一个方法返回的是ScheduledExecutorService对象,其实际上也是一个接口。
2023-11-04T10:44:37.png

对应线程池的使用方法即为对应接口定义的方法,在ExecutorService中,常用的使用线程池的方法为:

  • void execute(Runnable command):该方法会使用线程池中的线程执行对应的可执行代码;
  • Future<?> submit(Runnable task):该方法会使用线程池中的线程执行对应的有返回值的可执行代码,并返回对应任务(代表线程的可执行代码)的Future对象,调用对应的get()方法可获取对应的返回值。
  • void shutdown():该方法会关闭线程池,会执行完之前提交的任务(线程),但不接受新的任务(线程)。

接口中的量都是不可变的常量,因此要想管理对应线程池,比如设置线程池的大小等,必须使用对应线程池的对应类的方法,而如果用接口类型来接收创建的线程池就无法使用对应类的方法,只能使用对应接口的方法。因此,实现管理线程池需要将创建的线程池对象强转为对应的类的对象,通过使用对应类中的方法来实现对线程池的管理。例如,ExecutorService对应的线程池对象强转为对应的ThreadPoolExecutor,可以使用其中的public void setMaximumPoolSize(int maximumPoolSize)方法来设置线程池的最大线程数量。
2023-11-04T10:52:35.png

示例:
2023-11-04T10:52:50.png
2023-11-04T10:53:01.png
2023-11-04T10:53:18.png

Thread类的常用结构

1 创建Thread对象的构造器

  • public Thread(String name):除了空参构造器外,带name参数的构造器能够创建指定name的Thread对象。
    具体使用:在对应Thread子类中定义带name参数的构造器,再将该参数用在重载的父类的该带name参数的构造器中。
    2023-11-01T04:51:46.png
    2023-11-01T04:58:41.png
    2023-11-01T04:59:11.png

2 方法

注意,有的方法存在编译时异常,如果在线程子类重写的run()方法中调用这种方法时,必须进行异常处理而无法throws处理,因为父类run()方法本身没有throws异常,因此子类重写方法也无法throws异常。

  • public static Thread currentThread():这是静态方法,即不需要对象就可以调用。该方法会返回当前执行的线程的引用,相当于返回当前正在执行的线程。理解为返回正在执行的语句,即调用该方法对应的语句,对应的线程。
    2023-11-01T05:07:03.png
  • public final String getName():获取对应线程对象的name。
    2023-11-01T05:01:06.png
  • public final void setName(String name):设置对应线程对象的name。
    2023-11-01T05:01:39.png
  • public final boolean isAlive():判断对应线程对象是否处于存活状态。
    2023-11-01T05:13:06.png
    2023-11-01T05:12:35.png
  • public final void join() throws InterruptedException:当前线程进入阻塞状态,等待对应线程对象运行结束死亡后再继续运行。常常应用于某些线程需要其他线程的运行结果的场景。
    2023-11-01T05:24:38.png
    2023-11-01T05:24:14.png
  • public static void sleep(long millis) throws InterruptedException:当前线程休眠long毫秒(暂时停止执行)。
    2023-11-01T05:30:40.png
  • public static void yield():当前线程给调度器提示,表示对应线程对象自愿放弃当前对处理器的占用,但调度器可以忽略这种提示。即该方法并不一定能够达到切换线程的效果。
    2023-11-01T05:33:14.png

线程的优先级

1 什么是线程的优先级

线程的优先级是指示线程的重要或紧急程度的标记。
调度器会倾向于调度高优先级的线程,即高优先级的线程使用处理器的可能性越高。

需要注意的是,优先级高的线程不代表绝对地优先占用处理器,只是其占用处理器的概率更高。
一方面是因为具体的线程调度还受到操作系统和处理器的调度策略影响,另一方面对于多核cpu,多个线程在调度时可能并不在同一个“cpu”上抢占,即优先级并没有起到真正的作用。

2 Java中线程的优先级

Java中,线程的优先级以整数数字标识,有1~10个优先级,数字越大代表优先级越高。

线程Thread有3个静态固定不变的代表优先级的属性:

  • MAX_PRIORITY:代表最大优先级,即10。
  • MIN_PRIORITY:代表最小优先级,即1.
  • NORM_PRIORITY:代表默认优先级,为5,是每个线程默认的优先级。
    2023-11-01T08:31:53.png
    2023-11-01T08:33:42.png

线程Thread涉及优先级的方法:

  • public final int getPriority():获取对应线程对象的优先级。
    2023-11-01T08:34:40.png
  • public final void setPriority(int newPriority):设置对应线程对象的优先级。
    2023-11-01T08:35:16.png

守护线程与用户线程

在 Java 语言中,线程分为两类:用户线程和守护线程,默认情况下我们创建的线程或线程池都是用户线程,所以用户线程也被称之为普通线程。

守护线程(Daemon Thread)也被称之为后台线程或服务线程,守护线程是为用户线程服务的,当程序中的用户线程全部执行结束之后,守护线程也会跟随结束。 守护线程的角色就像“服务员”,而用户线程的角色就像“顾客”,当“顾客”全部走了之后(全部执行结束),那“服务员”(守护线程)也就没有了存在的意义,所以当一个程序中的全部用户线程都结束执行之后,那么无论守护线程是否还在工作都会随着用户线程一块结束,整个程序也会随之结束运行。
因此,守护线程可以理解为依赖于用户线程的存在而存在。

Java中典型的用户线程就是main()方法,对应的典型的守护线程是垃圾回收机制。
当所有非守护线程即用户线程都结束时,JVM就会退出。
2023-11-02T04:33:54.png

Thread中有两个涉及守护线程的方法:

  • public final boolean isDaemon():查看对应线程对象是否是守护线程。
  • public final void setDaemon(boolean on):将对应线程对象设置为守护线程。

示例:

  • 查看创建的线程是否是守护线程:
    2023-11-02T04:26:33.png
  • 设置守护线程,测试守护线程是否会因为用户线程的结束而结束:
    2023-11-02T04:30:43.png
    2023-11-02T04:30:54.png
    2023-11-02T04:31:17.png

线程的生命周期

线程可以理解为“小进程”,是动态的,因此具备生命周期。

Java中线程的生命周期类似于操作系统中线程的生命周期,操作系统中线程的生命周期包括以下几种状态:

  • 新建:该状态是指线程创建完成,还没开始启动的状态。
  • 就绪:该状态是指线程已启动,正在等待调度的状态。
  • 运行:该状态是指线程已被调度,正在执行的状态。
  • 阻塞:该状态是指线程在运行时,由于某些原因,需要等待才能继续执行的状态。该状态不同于就绪状态,该状态属于还没有就绪,等待就绪。
  • 死亡:该状态是指线程完全结束运行的状态。

Java中,线程的生命周期定义在Thread.State中,State是一个枚举类,后续会学习枚举类相关内容。
2023-11-02T08:42:06.png
Java中线程的状态有:

  • NEW:新建状态,即已经存在的可执行线程,但还没被start的状态。
    2023-11-02T08:42:35.png
  • RUNNABLE:理解为就绪或运行状态,即可执行线程被start后的状态,正在运行或正在排队占用处理器。
    2023-11-02T08:42:51.png
  • BLOCKED:属于阻塞状态,该状态特指等待同步锁的阻塞状态。
    2023-11-02T08:43:07.png
  • WAITING:属于阻塞状态,该状态特指等待另一个线程执行特定的操作的阻塞状态。
    2023-11-02T08:43:20.png
  • TIMED_WAITING:属于阻塞状态,该状态特指指定了等待时间的阻塞状态。
    2023-11-02T08:43:34.png
  • TERMINATED:属于死亡状态,即线程完成运行后的状态。
    2023-11-02T08:43:45.png

Java中线程的状态类比到操作系统中线程的状态,如图:
2023-11-02T08:52:51.png

之所以特别关注线程的生命周期,主要是为了实现有些功能在特定的时机去完成。

线程安全

1 什么是线程安全问题

线程安全问题是指,共享数据的多个线程在操作共享数据时,出现多个线程同时对同一数据进行操作的情况。

线程安全问题会导致不希望出现的数据重复或数据错误的问题。
例如,多个线程抢100张票,多个线程抢同一张票,就会出现重复票的数据重复问题,而多个线程抢了同一张票后同时对同一张票进行减票操作,就会出现少票或多出无效票的数据错误问题。

线程安全问题的根本原因在于2点:

  • 多个线程共享数据;
  • 支持多线程(即多线程并行运行)。

在Java中使用同步机制解决上述的线程安全问题。

线程同步是指,线程之间“协同”,即线程之间按照规定的先后次序运行。

2 同步机制一:同步代码块处理

同步代码块处理的原理是,对共享数据操作的相关代码进行“锁”控制,即每个线程对共享数据进行相关操作时,不允许其他线程执行共享数据的相关操作代码,必须等待当前操作共享数据的线程执行完对共享数据操作的代码。

相当于一旦有线程执行共享数据操作相关代码,就会对这块代码上锁,其他要执行该段代码的线程就只能等待。

通过使用关键字synchronized实现同步代码块处理,格式为:synchronized(同步监视器){可能引发线程同步的代码}

  • 同步监视器:同步监视器理解为“锁”,实质上就是一个对象。就是用一个对象来充当“锁”,任何对象都可以作为锁对象,但该对象必须对于可能参与线程安全的线程是唯一的,相当于这些线程面对的是同一把锁,否则无法起到“锁”的作用。
    换句话说,这些线程“看到”的锁对象必须是同一个对象,没有歧义存在。这也是对充当锁的对象的唯一要求。
    虽然任何符合要求的对象都可以作为同步监视器,通常可以用以下2个对象作为同步监视器:

    • 对于通过实现Runnable接口的方式创建的共享数据的多线程,直接用对应实现类的对象作为同步监视器。
    • 对于通过继承Thread类方式创建的共享数据的多线程,用该子类本身作为同步监视器,格式为子类名.class。之所以可以用类作为对象,是因为在Java中类本身也是对象,后续会详细学习这点。
  • 可能引发线程同步的代码:就是指对共享数据进行操作的相关代码。被synchronized锁住的代码块就是每次只允许1个线程运行的代码段。其他要运行这段代码块的线程只能等待正在运行这段代码块的线程运行完这段代码。
    因此,被synchronized锁住的代码不能被锁多了,也不能被锁少了。锁多了可能导致原代码的逻辑改变,锁少了可能导致不彻底的线程同步处理,即再次出现线程同步问题。

示例:

  • 未进行线程同步处理:
    2023-11-02T12:38:24.png
    2023-11-02T12:38:35.png
    2023-11-02T12:38:49.png
  • 对通过实现Runnable方式创建共享数据的多线程进行线程同步代码块处理:
    2023-11-02T12:40:49.png
    2023-11-02T12:41:00.png
    2023-11-02T12:41:18.png
  • 对通过继承Thread类方式创建共享数据的多线程进行线程同步代码块处理:
    2023-11-02T12:47:53.png
    2023-11-02T12:48:05.png
    2023-11-02T12:48:18.png

3 同步机制二:同步方法处理

如果共享数据相关操作的所有代码都在一个方法中,此时可以通过同步方法处理的方式解决线程安全问题。

同步方法处理,是将一个方法声明为同步方法,使得每次只允许一个线程使用该方法,其他使用该方法的线程必须等待正在使用该方法的线程完成对该方法对应代码的运行。

通过在定义方法时,在方法的返回值类型之前加入关键字synchronized完成对一个方法声明为同步方法,格式为:
权限修饰符 [关键字] synchronized 返回值类型 方法名(参数){}

要注意的是,类似于同步代码块处理需要同步监视器,同步方法处理要求共享数据的多线程“看到”的是同一个方法,否则无法实现同步处理的效果。

  • 对于通过实现Runnable接口的方式创建的多个共享数据的线程,由于多个线程使用的是同一个实现类对象,因此它们使用的同步方法自然地是同一个。相当于此时同步方法的“同步监视器”为该实现类对象(this)。
  • 对于通过继承Thread类的方式创建的多个共享数据的线程,由于多个线程是同一个子类的不同对象,因此要使得它们使用的同步方法是同一个,只能将子类中定义的同步方法定义为static。相当于此时同步方法的“同步监视器”为该子类(子类名.class)。

可以这样理解“同步监视器”:共享数据的各个线程所执行的可能引发线程安全的代码是在自己的run()方法中。

  • 若用同步代码块处理引发线程安全的代码,那么就是对run()方法中的代码处理,此时各个线程都有一份自己的有同步处理的run()方法,此时只有当每个线程对应的同步处理的代码是同一个同步监视器“监视”,才能实现对各个线程使用这段代码的“监视”,从而控制每次只有一个线程正在运行该段代码。
    形象地理解为,本来是对同一段代码的控制,由于每个线程各自都有一份,只能转换为对同一个“同步监视器”的监视,虽然每个线程都各自有一份操作代码,但它们各自代码都是同一个“监视器”,只要通过对这个监视器的监视,就能实现对线程使用相同操作代码的控制。
  • 若用同步方法处理引发线程安全的代码,那么就是run()方法中调用同步方法,此时各个线程各自的run()方法都调用该同步方法,而由于同步方法没有显式的同步监视器来监视,此时只有当它们调用的是同一个同步方法才能实现对该方法的“监视”,从而控制每次只有1个线程正在运行该方法。
    形象地理解,虽然每个线程都各自调用操作方法,但它们调用的是同一个操作方法,只要通过“监视”这个方法,就能实现对线程使用该方法的控制。

示例:

  • 对通过实现Runnable方式创建共享数据的多线程进行线程同步方法处理
    2023-11-03T03:24:28.png
    2023-11-03T03:24:38.png
    2023-11-03T03:24:54.png
  • 对通过继承Thread类方式创建共享数据的多线程进行线程同步方法处理:
    2023-11-03T03:23:47.png
    2023-11-03T03:23:58.png
    2023-11-03T03:24:11.png

4 同步机制三:Lock锁处理

在Java5.0时新增了Lock锁。

Lock锁是指java.util.concurrent.locks.Lock接口,其具有synchronized关键字相同的功能。
2023-11-03T08:21:17.png

使用Lock锁实现同步处理,通常是通过使用其典型的实现类ReentrantLock的方式实现,具体地:
2023-11-03T08:24:00.png

  • 实例化ReentrantLock对象;
  • 调用该对象的lock()方法,实现“加锁”功能;
  • 在“上锁”代码后,需要调用锁对象的unlock()方法,以释放锁。

使用Lock方式实现同步处理的一般格式:
2023-11-03T08:23:22.png

使用ReentrantLock实现类对应的同步处理的一般格式:
2023-11-03T08:24:10.png

Lock锁与synchronized关键字使用时最大的区别为,需要手动释放锁,因此一般配合使用try-finally块,以避免忘记释放锁。

使用Lock锁的注意点:
2023-11-03T08:25:20.png

示例:
注意,如果使用继承方式创建共享数据的线程,Lock锁也要求必须对这些线程是同一个。
2023-11-03T08:25:44.png
2023-11-03T08:25:55.png
2023-11-03T08:26:05.png

线程死锁

1 什么是线程死锁

线程死锁是指,多个线程各自占用着别的线程现在所需要的同步资源,由于每个线程不拿到自己现在所需要的同步资源无法释放已占用的同步资源,而引发的多个线程互相等待别的线程释放所需的同步资源的僵持状况。

线程死锁,本质上是多个线程既无法继续运行,也无法结束运行,陷入无法打破的无限等待(不属于异常,也不会有提示)。

根据目前所学的,让线程等待的同步资源是指同步监视器,即“锁对象”。
如果一个线程拿到一把锁a,执行相应的代码,又需要另一把锁b完成后续代码的执行才能释放两把锁,但这时另一个线程拿到锁b,执行相应的代码,又需要锁a完成后续代码的执行才能释放两把锁,这就引发了线程死锁。

2 关于线程死锁的注意点

产生线程死锁的根本原因是使用同步资源。同步资源对于所有线程来说是公共的、会引发等待的。例如,“同步监视器”可以是任何对象。

在开发中,使用同步资源时要避免线程死锁现象:

  • 使用专门的算法、原则;
  • 尽量避免使用同步资源;
  • 尽量避免嵌套使用同步资源。

3 线程死锁的示例

2023-11-03T06:43:26.png
2023-11-03T06:43:05.png
2023-11-03T06:43:16.png
2023-11-03T06:43:44.png

线程通信

1 什么是线程通信

线程通信是指,一个功能的实现需要多个线程配合完成,可以理解为多个线程的状态(运行或阻塞)受到相互的影响,这就形象地称为线程通信。

2 线程通信相关方法

以下是实现线程通信的方法,这些方法针对的对象为同步监视器,换句话说,这些方法只能通过同步监视器对应对象来调用,而不是通过线程对象来调用:

  • public final void wait() throws InterruptedException: 该方法会使当前线程,即对应同步监视器对象当前“监视”的线程,进入等待(或者说阻塞)状态,并释放其“拿到”的锁,即对应的同步监视器对象不再“监视”该线程。
    2023-11-04T04:06:20.png
  • public final void notify(): 该方法会使“监视”当前线程的同步监视器对象唤醒一个被其阻塞的线程,对当前线程无实质影响。
    2023-11-04T04:07:31.png
  • public final void notifyAll(): 该方法会使“监视”当前线程的同步监视器对象唤醒所有被其阻塞的线程,对当前线程无实质影响。
    2023-11-04T04:08:30.png

关于上述三个方法的注意点:

  • 上述三个方法只能由同步监视器对象来调用,也就是说,上述三个方法只能用在synchronized同步代码块或synchronized同步方法中。
  • 由于任何对象都可以作为同步监视器,因此可以理解为,上述三个方法实质上是在java.lang.Object类中定义的。
  • wait()方法使用时,要同时注意给出对应的唤醒方法,并且注意使用时的位置,否则会导致所有参与线程最终都处于阻塞状态的情况,导致程序无法正常执行结束。

wait()sleep()的区别:

  • wait()方法定义在Object中,由同步监视器对象来调用
    sleep()方法定义在Thread中,由线程对象来调用
  • wait()方法只能用在synchronized同步代码块或synchronized同步方法中
    sleep()方法没有这种限制
  • wait()方法除了能让线程阻塞外,还会让线程释放锁
    sleep()方法只能让线程阻塞

3 示例

2023-11-04T04:13:55.png
2023-11-04T04:14:07.png
2023-11-04T04:14:30.png

Last Modified: November 5, 2023