多线程

重点是创建多线程的方式,如何处理线程安全问题?

前言

程序:一组指令的集合,一段静态的代码
进程:程序的一次执行过程,或是正在内存中运行的应用程序。是动态的。进程作为操作系统调度和分配资源的最小单位(亦是系统运行程序的基本单位),系统在运行时会为每个进程分配不同的内存区域。
线程:是程序内部的一条执行路径,一个进程中至少有一个线程。一个进程中的多个线程共享相同的内存单元,它们从同一个堆中分配对象,可以访问相同的变量和对象。这就使得线程间通信更简便、高效。但多个线程操作共享的系统资源可能就会带来安全的隐患。
不同的进程之间是不共享内存的。进程之间的数据交换和通信的成本很高。

线程调度和多线程的优点

分时调度:所有线程轮流使用 CPU 的使用权,并且平均分配每个线程占用 CPU 的时间。
抢占式调度:让优先级高的线程以较大的概率优先使用 CPU。如果线程的优先级相同,那么会随机选择一个(线程随机性),Java 使用的为抢占式调度。
优点:1. 提高应用程序的响应。对图形化界面更有意义,可增强用户体验 2. 提高计算机系统 CPU 的利用率 3. 改善程序结构。将既长又复杂的进程分为多个线程,独立运行,利于理解和修改

单核 CPU 在一个时间单元内,只能执行一个线程的任务。增强本身性能和多核,但效率不是线性增长。一旦涉及多个元素,元素之间的关系在后来将会成为制约的关键还受本身环境的制约和限制。

并行(Parallelism):并行是指在同一时刻执行多个任务或操作,这些任务可以在多个处理器核心上同时运行,或者在多台计算机上同时进行。并行处理的目的是提高系统的性能和效率,通过同时执行多个任务来加速完成工作。通常需要具备多个处理单元(如多核处理器或多台计算机)来实现并行处理。
并发(Concurrency):并发是指在相同的时间段内处理多个任务或操作,但不一定是同时执行,而是在短时间内交替执行这些任务。并发处理的目的是更好地利用系统资源,提高系统的响应性,允许多个任务在不同的时间点上交替执行,从而实现多任务处理。并发可以通过多线程、进程、协程等技术来实现。

并行和并发都涉及多任务处理,但它们关注的是任务处理的方式和目的不同。并行是一种特殊的并发,它要求任务在同一时刻同时执行,通常需要多个处理单元的支持。并行旨在加速任务的完成,特别适用于需要高性能计算的场景。并发则更关注任务之间的互相切换和资源的共享,通常用于改善系统的响应性和资源利用率。在并发中,任务可以在不同的时间点上交替执行,因此更适用于多任务协同工作的场景。

线程的创建和启动

所有的线程对象都必须是 Thread 类或其子类的实例。
每个线程都是通过某个特定 Thread 对象的 run()方法来完成操作的,因此把 run()方法体称为线程执行体。
通过该 Thread 对象的 start()方法来启动这个线程,而非直接调用 run()。
要想实现多线程,必须在主线程中创建新的线程对象。
start方法的作用包括启动线程 执行run方法。

方式 1:继承 Thread 类

Java 通过继承 Thread 类来创建并启动多线程的步骤如下:

  1. 定义 Thread 类的子类,并重写该类的 run()方法,该 run()方法的方法体就代表了线程需要完成的任务
  2. 创建 Thread 子类的实例,即创建了线程对象
  3. 调用线程对象的 start()方法来启动该线程
  • 如果自己手动调用 run()方法,那么就只是普通方法,没有启动多线程模式。
  • run()方法由 JVM 调用,什么时候调用,执行的过程控制都有操作系统的CPU 调度决定。
  • 想要启动多线程,必须调用 start 方法。
  • 一个线程对象只能调用一次 start()方法启动,如果重复调用了,则将抛出以上的异常 IllegalThreadStateException。

方式 2:实现 Runnable 接口

Java 有单继承的限制,无法继承 Thread 类时,可以实现 Runnable 接口,重写 run()方法,然后再通过 Thread 类的对象代理启动和执行线程体 run()方法。

  1. 定义 Runnable 接口的实现类,并重写该接口的 run()方法,该 run()方法的方法体同样是该线程的线程执行体。
  2. 创建 Runnable 实现类的实例,并以此实例作为 Thread 的 target 参数来创建Thread 对象,该 Thread 对象才是真正的线程对象。
  3. 调用线程对象的 start()方法,启动线程。调用 Runnable 接口实现类的 run 方法。
    无论哪种方式,最终线程都会执行run()方法中的代码。本质上都是在用Thread类中的start方法(需要认真体会)。

Thread 类实际上也是实现了 Runnable 接口的类。

区别:
• 继承 Thread:线程代码存放 Thread 子类 run 方法中。
• 实现 Runnable:线程代码存在接口的子类的 run 方法。
实现 Runnable 接口比继承 Thread 类所具有的优势:
• 避免了单继承的局限性
• 多个线程可以共享同一个接口实现类的对象,非常适合多个相同线程来处理同一份资源。
• 增加程序的健壮性,实现解耦操作,代码可以被多个线程共享,代码和线程独立。

Thread 类的常用结构

构造器

• public Thread() :分配一个新的线程对象。
• public Thread(String name) :分配一个指定名字的新的线程对象。
• public Thread(Runnable target) :指定创建线程的目标对象,它实现了 Runnable 接口中的 run 方法
• public Thread(Runnable target,String name) :分配一个带有指定目标新的线程对象并指定名字。

常用方法系列 1

• public void run() :此线程要执行的任务在此处定义代码。
• public void start() :导致此线程开始执行; Java 虚拟机调用此线程的 run 方法。
• public String getName() :获取当前线程名称。
• public void setName(String name):设置该线程名称。
• public static Thread currentThread() :返回对当前正在执行的线程对象的引用。在Thread 子类中就是 this,通常用于主线程和 Runnable 实现类
• public static void sleep(long millis) :使当前正在执行的线程以指定的毫秒数暂停(暂时停止执行)。
• public static void yield():yield 只是让当前线程暂停一下,让系统的线程调度器重新调度一次,希望优先级与当前线程相同或更高的其他线程能够获得执行机会,但是这个不能保证,完全有可能的情况是,当某个线程调用了 yield 方法暂停之后,线程调度器又将其调度出来重新执行。

常用方法系列 2

• public final boolean isAlive():测试线程是否处于活动状态。如果线程已经启动且尚未终止,则为活动状态。
• void join() :等待该线程终止。
void join(long millis) :等待该线程终止的时间最长为 millis 毫秒。如果 millis 时间到,将不再等待。
void join(long millis, int nanos) :等待该线程终止的时间最长为 millis 毫秒 + nanos 纳秒。
在线程a中通过b调用join,则a阻塞 b执行完后a继续执行。
• public final void stop():已过时,不建议使用。强行结束一个线程的执行,直接进入死亡状态。run()即刻停止,可能会导致一些清理性的工作得不到完成,如文件,数据库等的关闭。同时,会立即释放该线程所持有的所有的锁,导致数据得不到同步的处理,出现数据不一致的问题。
• void suspend() / void resume() : 这两个操作就好比播放器的暂停和恢复。二者必须成对出现,否则非常容易发生死锁。suspend()调用会导致线程暂停,但不会释放任何锁资源,导致其它线程都无法访问被它占用的锁,直到调用 resume()。已过时,不建议使用。

常用方法系列 3

每个线程都有一定的优先级,同优先级线程组成先进先出队列(先到先服务),使用分时调度策略。优先级高的线程采用抢占式策略,获得较多的执行机会。每个线程默认的优先级都与创建它的父线程具有相同的优先级。
• Thread 类的三个优先级常量:
– MAX_PRIORITY(10):最高优先级
– MIN _PRIORITY (1):最低优先级
– NORM_PRIORITY (5):普通优先级,默认情况下 main 线程具有普通优先级。
• public final int getPriority() :返回线程优先级
• public final void setPriority(int newPriority) :改变线程的优先级,范围在[1,10]之间。

多线程的生命周期

JDK1.5 之前:5 种状态【为了帮助理解】

线程的生命周期有五种状态:新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、死亡(Dead)。CPU 需要在多条线程之间切换,于是线程状态会多次在运行、阻塞、就绪之间切换。
方法调用导致状态改变。

JDK1.5 及之后:6 种状态


阻塞状态划分更加细致。

线程安全问题及其解决

争论焦点:不同线程共享资源的写操作时可能出现的矛盾。等待会让问题暴露得更加明显。【售票的重票和错票问题,不该进去进去了 数据还没更新】
解决方法:一次只能有一个线程操作数据。【JAVA --> 同步机制 synchronized】
采用继承类的方法 --> 设置为类属性 静态
采用实现接口的方法 --> 参数是共用的,所以不需要做额外设置。更加灵活。

同步代码块

synchronized 关键字可以用于某个区块前面,表示只对这个区块的资源实行互斥访问。

synchronized(同步锁){
  需要同步的代码,即操作共享数据的代码
}

同步锁即同步监视器,可以使用任何一个类的对象,但是多个线程必须共用同一个监视器。其它线程必须等待。

同步方法

synchronized 关键字直接修饰方法,表示同一时刻只有一个线程能进入这个方法,其他线程在外面等着。

public synchronized void method(){
可能会产生线程安全问题的代码
}

调一个同步方法就握了一把锁。
对于同步代码块来说,同步锁对象是由程序员手动指定的(很多时候也是指定为 this 或类名.class),但是对于同步方法来说,同步锁对象只能是默认的:
• 静态方法:当前类的 Class 对象(类名.class)
• 非静态方法:this

懒汉式线程安全问题

懒汉式:延迟创建对象,第一次调用 getInstance 方法再创建对象。此时多个线程访问 存在时间差,如果不及时锁上创建的例子可能就不止一个。
可以使用同步方法和同步代码块来解决,
还可以在外面加双重确认

public static LazyOne getInstance3(){
 if(instance == null){
   synchronized (LazyOne.class) {
     try {
       Thread.sleep(10);//加这个代码,暴露问题
      } catch (InterruptedException e) {
       e.printStackTrace();
     }
     if(instance == null){
       instance = new LazyOne();
     }
   }
 }
  return instance;
 }

对象的创建是一个很复杂的过程,new是最后一个步骤。为了避免指令重拍,需要加上volatile 关键字,避免指令重排。

public class LazySingle {
 private LazySingle(){}
 
 public static LazySingle getInstance(){
   return Inner.INSTANCE;
 }
 
 private static class Inner{
   static final LazySingle INSTANCE = new LazySingle();
 }
}

内部类只有在外部类被调用才加载,产生 INSTANCE 实例;又不用加锁。
此模式具有之前两个模式的优点,同时屏蔽了它们的缺点,是最好的单例模式。

死锁

陷入死循环。不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁。
一旦出现死锁,整个程序既不会发生异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。
有的代码每次都成功不意味着没有问题,依然有可能存在出现问题的可能。

诱发死锁的原因:
• 互斥条件
• 占用且等待
• 不可抢夺(或不可抢占)
• 循环等待
以上 4 个条件,同时出现就会触发死锁。

解决死锁

解决死锁:
死锁一旦出现,基本很难人为干预,只能尽量规避。可以考虑打破上面的诱发条件。
针对条件 1:互斥条件基本上无法被破坏。因为线程需要通过互斥解决安全问题。
针对条件 2:可以考虑一次性申请所有所需的资源,这样就不存在等待的问题。
针对条件 3:占用部分资源的线程在进一步申请其他资源时,如果申请不到,就主动释放掉已经占用的资源。
针对条件 4:可以将资源改为线性顺序。申请资源时,先申请序号较小的,这样避免循环等待问题。

JDK5.0 新特性:Lock(锁)

Lock 通过显式定义同步锁对象来实现同步。同步锁 使用Lock 对象充当
java.util.concurrent.locks.Lock 接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock 对象加锁,线程开始访问共享资源之前应先获得 Lock 对象。
Lock 锁也称同步锁,加锁与释放锁方法,如下:
– public void lock() :加同步锁。
– public void unlock() :释放同步锁。

class A{
   //1. 创建 Lock 的实例,必须确保多个线程共享同一个 Lock 实例
  private final ReentrantLock lock = new ReenTrantLock();
  public void m(){
     //2. 调动 lock(),实现需共享的代码的锁定
    lock.lock();
    try{
    //保证线程安全的代码;
    }
    finally{
     //3. 调用 unlock(),释放共享代码的锁定
      lock.unlock(); 
    }
  }
}

注意:如果同步代码有异常,要将 unlock()写入 finally 语句块。
synchronized 需要在花括号结束后释放对同步监视器的调用,Lock 更加灵活,作为接口有多种实现类,适合更多更复杂的场景,效率更高。

线程的通信

多个线程做一个任务,需要互相协作配合 有规律运行,使用等待唤醒机制。
在一个线程满足某个条件时,就进入等待状态(wait() / wait(time)), 等待其他线程执行完他们的指定代码过后再将其唤醒(notify());或可以指定
wait 的时间,等时间到了自动唤醒;在有多个线程进行等待时,如果需要,可以使用 notifyAll()来唤醒所有的等待线程。wait/notify 就是线程间的一种协作机制。

  1. wait:线程不再活动,不再参与调度,进入 wait set 中,因此不会浪费 CPU 资源,也不会去竞争锁了,这时的线程状态是 WAITING 或 TIMED_WAITING。它还要等着别的线程执行一个特别的动作,也即“通知(notify)”或者等待时间到,在这个对象上等待的线程从 wait set 中释放出来,重新进入到调度队列(ready queue)中。
  2. notify:则选取所通知对象的 wait set 中的一个线程释放;
  3. notifyAll:则释放所通知对象的 wait set 上的全部线程。

如果能获取锁,线程就从 WAITING 状态变成 RUNNABLE(可运行)状态;否则,线程就从 WAITING 状态又变成 BLOCKED(等待锁) 状态。

调用 wait 和 notify 需注意的细节

  1. wait 方法与 notify 方法必须要由同一个锁对象调用。因为:对应的锁对象可以通过 notify 唤醒使用同一个锁对象调用的 wait 方法后的线程。
  2. wait 方法与 notify 方法是属于 Object 类的方法的。因为:锁对象可以是任意对象,而任意对象的所属类都是继承了 Object 类的。
  3. wait 方法与 notify 方法必须要在同步代码块或者是同步函数中使用。因为:必须要通过锁对象调用这 2 个方法。否则会报java.lang.IllegalMonitorStateException 异常。
posted @ 2023-09-23 17:03  芋圆院长  阅读(5)  评论(0编辑  收藏  举报