• Java进程与线程


    进程与线程之间的关系


    一、简介

    1. 什么是进程?

      进程就是正在运行的程序(程序本身是静态的,而进程则是动态的),它会占用对应的内存区域,由CPU进行执行与计算。

    2. 进程的特点:

      • 独立性
        进程是系统中独立存在的实体,它可以拥有自己独立的资源,每个进程都拥有自己私有的地址空间,在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间。
      • 动态性
        进程与程序的区别在于,程序只是一个静态的指令集合,而进程是一个正在系统中活动的指令集合,程序加入了时间的概念以后,称为进程,具有自己的生命周期和各种不同的状态,这些概念都是程序所不具备的。
      • 并发性
        多个进程可以在单个处理器CPU上并发执行,多个进程之间不会互相影响.
    3. 什么是线程?

      线程是操作系统所能够进行运算调度的最小单位它被包含在进程之中,是进程中的实际运作单位,所以说【进程包含线程】。

    4. 线程的特点:

      • 一个进程可以包括一个或者多个线程,但至少包括一个线程且为主线程来调用本进程中的其他线程。
      • 我们看到的进程切换,实际上切换的也是不同进程的主线程。
      • 多线程可以让同一个进程同时并发处理多个任务,相当于扩展了进程的功能。
      • 每个线程都有自己的局部变量,同时拥有进程的全局变量。
      • 多线程经常需要读写共享数据,存在线程安全问题。
      • 多线程模型是Java程序最基本的并发模型。
      • Java中的网络读写、数据库操作、UI渲染、Web开发等都高度依赖于多线程。
    5. 线程拥有**【随机性】,即常见的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定**,程序并不能自己决定什么时候执行以及执行多长时间

    6. **一个Java程序实际上就是一个JVM进程。**JVM 进程先使用主线程来执行main()方法,然后又接着启动其他线程,此外 JVM 还存在着垃圾回收等其他工作线程。

    7. 当我们不使用线程池中的线程时,线程池里的线程会处于一种“休眠状态”,当再次被调用时恢复成使用状态(节约资源)。

    8. 数据库的连接是一种有限且昂贵的资源。在一般情况下,我们应该使用**【数据库连接池】**来减轻服务器压力、提高程序的运行效率,使得服务器能够支持并容纳更多的客户服务。

    9. 数据库连接池是不等同于线程池,它只是另外一种线程池。


    二、多进程

    1. 缺点:开销大、读写慢、编写复杂度高。
      • 创建多进程的开销比创建多线程的开销要大,尤其是在Windows系统上。
      • 进程之间的通信比线程之间的通信慢(跨部门合作)。线程间的通信读写同一个变量,速度很快。
      • 复杂度高、调试困难。
    2. 优点:稳定性高。
      • 在多进程的情况下,单一进程的崩溃不会影响其他进程。
      • 在多线程的情况下,单一线程的崩溃会直接导致整个进程崩溃(挂载在该进程下的所有线程也会崩溃)。
    3. 综上所述,我们通常采用多线程而不是多进程。

    三、创建新线程

    1. 简介:

      • 只有当.start()运行时,程序才会调用线程的run()方法。
      • 直接调用 run() 方法是无效的,程序并不会得到运行!因为当直接调用 run() 方法时,相当于只调用了一个普通的 Java 方法,当前进程也不会启动新的线程,所以当前线程不会发生任何改变。
      • start()方法的内部调用了一个native方法,表明该方法是由 JVM 虚拟机内部的C代码实现的。
      private native void start0();
      
      • 1
    2. Thread 与 Runnable 的区别:

      • Thread,普通类,只能被继承。
      • Runnable,接口,可以被实现。
      • Thread 继承并扩展了 Runnable,两者在使用时并无明显区别。
      • 线程池不支持直接使用继承自 Thread 的类。
      class Thread implements Runnable {}
      
      • 1
    3. 继承 Thread

      class MyThread extends Thread {
          @Override
          public void run() {
              System.out.println("start new thread!");
          }
      }
      
      Thread t = new MyThread();
      t.start(); 
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      new Thread(() -> {
      	System.out.println("start new thread!");
      }).start();
      
      • 1
      • 2
      • 3
    4. 实现 Runnable

      class MyRunnable implements Runnable{
          @Override
          public void run() {
              System.out.println("");
      }
        
      Thread t = new Thread(new MyRunnable());
      t.start(); 
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      new Thread(new Runnable() {
          @Override
          public void run() {
              System.out.println("");
          }
      }).start();
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6

    四、常用操作

    1. .start():启动线程,执行run()方法

      t.start()
      
      • 1
    2. .sleep():强迫线程休眠一段时间。

      t.sleep(1000);
      
      • 1
    3. .setPriority(10):设置线程优先级。优先级高的线程会被操作系统优先调度,但是并不能保证其一定会被优先执行,优先级从1~10共10个级别,其中默认值为5。

      // 1~10, 默认值5
      t.setPriority(int n);
      
      • 1
      • 2
    4. .join():

      • 阻塞当前线程直到调用线程完成,可以指定最长等待时间。
      • 对于已经结束的线程再次调用join,无法生效。
      t.join();
      t.join(8000);
      
      • 1
      • 2
    5. .getName():获取当前线程名

      Thread.currentThread().getName();
      
      • 1
    6. .getId():获取当前现场 id 号

      Thread.currentThread().getId()
      
      • 1
    7. 线程组

      Thread[] ts = new Thread[] { new AddStudentThread()new DecStudentThread()new AddTeacherThread()new DecTeacherThread() };
      
      • 1

    五、线程的状态

    1. 一个线程对象只能调用一次start()方法。一旦run()方法执行结束,线程终止。

      t.start();
      
      • 1
    2. 线程拥有6种状态(不同JDK版本可能会有差别):

      1. New:新创建的线程,尚未执行。
      2. Runnable(Running):运行中的线程,正在执行 run() 方法的Java代码。
      3. Blocked:运行中的线程,因为某些操作被阻塞而挂起。
      4. Waiting:运行中的线程,因为某些操作在等待中。
      5. Timed Waiting:运行中的线程,因为执行 sleep() 方法正在计时等待。
      6. Terminated:线程已终止,因为 run() 方法执行完毕。
    3. 线程从 New 开始,途中可能经过几种不同的状态,但最后总会达到 terminated 终止线程状态。

      在这里插入图片描述

    4. 线程终止的原因一共有3种

      1. 正常终止:执行到run()方法的return语句。
      2. 异常终止:run()方法中抛出了未捕获的异常。
      3. 手动强制终止:手动调用线程Thread的stop()方法,不推荐使用。

    六、中断线程

    1. 什么时候需要中断线程?下载 100GB 的资源,中途取消。

    2. 利用.interrupt():手工编写中断代码

      • 编写线程类,其中利用 isInterrupted() 判断是否接收到中断请求。
      • 线程发送中断请求 .interrupt()。
      // 在线程中手动编写中断逻辑
      class MyThread extends Thread {
          public void run() {
              while (! isInterrupted()) {
                  System.out.println(n + " hello!");
              }
          }
      }
      
      // 向线程发送中断请求
      t.interrupt();
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
    3. 利用自定义变量

      • 自行定义的变量 running,并附上 volatile 关键字。
      • 本质与使用.interrupt()无异。
      // 线程当中的具体逻辑
      class HelloThread extends Thread {
          // 自定义变量 + volatile
          public volatile boolean running = true;
          
          public void run() {
              while (running) {
                  System.out.println(n + " hello!");
              }
              System.out.println("end!");
          }
      }
      
      // 标识线程间共享变量 running 为 false
      t.running = false; 
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
    4. volatile 关键字

      • 线程间共享变量需要使用 volatile 关键字标记,确保每个线程都能读取到更新后的变量值。
      • volatile关键字解决的是【可见性问题】,当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。

      在这里插入图片描述

      因此,volatile 关键字的作用为:

      • 每次访问变量时,总是获取主内存的最新值;
      • 每次修改变量后,立刻回写到主内存。

    七、守护线程

    守护线程 而不是 守护进程。

    1. 前言

      • Java 程序的入口为 JVM 启动的 main()方法(主线程)。
      • 通常情况下,只有当所有线程运行结束,JVM才会退出、整体进程才会结束;只要有一个线程没有结束,JVM 便不会停止。对于此,现在我们想要在主线程和 JVM 退出的情况下,部分线程还能够正常运行而不是跟随退出,使用【守护进程】。
      • 在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
    2. 实现代码:

      Thread t = new MyThread();
      
      t.setDaemon(true);  // 修改为【守护进程】
      
      t.start();
      
      • 1
      • 2
      • 3
      • 4
      • 5
    3. 注意:在编写守护线程的时候,守护线程不能持有任何需要关闭的资源(例如打开文件)。因为虚拟机退出时,守护线程没有任何机会来关闭文件,可能会导致数据丢失。


    八、线程同步

    1. 目的:解决线程安全问题。

    2. 弊端:性能下降(synchronized 无法并发执行)。

    3. 实际做法:将所有可能会存在冲突的操作定义为【原子操作】。

    4. 加锁原则

      • 能不用锁就不用锁。
      • 能用小锁就不用大锁。
      • 能用两个锁就不要用一个锁(避免并发限制、降低效率)。
    5. synchronized关键字

      ​ Java 使用 synchronized 对一个对象进行加锁(保证代码块在任意时刻只有一个线程在执行)。这种加锁和解锁之间的代码块我们称之为临界区

      1. 找出修改共享变量的线程代码块。
      2. 选择一个共享实例作为锁。
      synchronized( myLock ) { // 获取锁
        
          // 临界区代码块
        
      } // 释放锁
      
      • 1
      • 2
      • 3
      • 4
      • 5
    6. 在使用synchronized的时候,不必担心程序抛出异常而导致锁未释放,因为无论有无异常,Java程序总会正确释放锁。

      public void add(int m) {
          synchronized (obj) {
              if (m < 0) {
                  throw new RuntimeException();
              }
              this.value += m;
          } // 无论有无异常,都会在此释放锁
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
    7. Java规范中定义了几种原子操作,这些操作并不需要加锁:

      • 基本类型赋值(long、double除外),例如:int n = 1;
      • 引用类型赋值,例如:List list = anotherList;
      • long和double是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long和double的赋值作为原子操作实现的。

    九、同步方法

    1. 简介:

      ​ 在使用synchronized的时候,锁住的是哪个对象非常重要。让线程自己选择锁住的对象会使得代码逻辑混乱、不利于封装。更好的方法是对 synchronized 进行逻辑封装。而且不止局部代码,方法也可以被锁住(对方法加锁实际上就是对该方法所属实例this进行加锁),以下两种方法是等价的:

      public void add(int n) {
         synchronized(this) {
           count += n;
      	} // 解锁
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      // 对方法进行加锁,锁住 this
      public synchronized void add(int n) { 
          count += n;
      } // 解锁
      
      • 1
      • 2
      • 3
      • 4

      // 每次调用该方法时都会锁住自身 this,线程无需关心具体的逻辑。(且对创建多个对象进行并发操作无影响)

    2. 关于【线程安全】

      • 无特殊说明,默认一个类不是线程安全的。
      • 如果一个类被设计为允许多线程访问,那么我们说这个类是“线程安全的”。
      • 对于所有变量都被final修饰的类,它是“线程安全的”(变量只能读而不能修改)。
      • 对于没有变量,只提供静态方法的类,它是“线程安全的”。

    十、死锁

    1. 前言:可重入锁

      ​ JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁就叫“可重入锁”。例如在以下代码中,当传入 n < 0 时,线程一共会获取2次当前实例的this锁。

      ​ 可重入锁每获取一次锁,记录都会加 1;每退出一次 synchronized 块,记录都会减 1。当减到 0 的时候,程序才会真正释放锁

      public class Counter {
          private int count = 0;
      
          public synchronized void add(int n) {
              if (n < 0) {
                  dec(-n);
              } 
          }
      
          public synchronized void dec(int n) {
              count += n;
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
    2. 主题:死锁(deadlock)

      • 产生原因:某线程获得锁之后一直没有释放。死锁会一直卡在运行状态(持续空转),有时会使 cpu 使用率一直处于 100%,严重浪费资源。
      • 死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。
      • **如何避免死锁?**答案是在编写代码时让线程获取锁的顺序一致。

    十一、使用wait和notify

    1. 为什么要使用 wait 与 notify ?

      • 单一使用 synchronized 并没有解决多线程协调的问题,容易造成死锁与空转、严重浪费服务器资源。
      • wait 与 notify 原则:与 synchronized 一起使用,当条件不满足时,线程进入等待状态;当条件满足时,线程进入唤醒状态,继续执行任务。wait() 不是一个普通的 Java 方法,而是定义在 Object 类中的一个 **native **方法。
    2. while 循环与 wait/notify:

      • 必须在 synchronized 块中才能调用 wait() ,避免服务器空转和资源浪费。
      • 这里的关键是 wait() 必须获取 this 锁,因此调用this.wait()。wait()方法调用时,会释放线程获得的锁;wait()方法返回后,线程又会重新试图获得锁。
      • wait()方法不会主动返回,直到将来某个时刻线程从等待状态被其他线程 notify 唤醒,wait()方法才会返回并继续执行下一条语句。
      • 如何唤醒?在相同的锁对象上调用 notify 方法(唤醒正在等待中的线程)。通常来说 notifyAll() 更安全,它避免了通知不到的情况。
        • notifyAll():唤醒所有当前正在 this 锁中等待的线程。
        • notify():随机唤醒一个锁,具有随机性而不是顺序性。
      public synchronized String getTask() {
          while (queue.isEmpty()) {
              this.wait();
          }
          return queue.remove();
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      public synchronized void addTask(String s) {
          this.queue.add(s);
          this.notify(); // 唤醒在this锁等待的线程
      }
      
      • 1
      • 2
      • 3
      • 4
    3. 【注意】:正确编写多线程代码是非常困难的,需要考虑的地方非常多,不正确编写多线程代码会导致程序运行时不正常。


    十二、使用ReentrantLock

    re-entrant Lock,可重入锁

    reentrantLock 用来配合 Conditon 进行工作,就像 wait 与 notify 的组合

    1. 简介:
      • Java 5开始,引入了一个高级的处理并发的java.util.concurrent包,它提供了大量更高级的并发功能,能大大简化多线程程序的编写。

      • ReentrantLock是用来代替 synchronized 的,它更安全。

    // synchronized
    public void add(int n) {
        synchronized(this) {
            count += n;
        }
    } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
     // reentantLock
     public class Counter {
         private final Lock lock = new ReentrantLock();
         private int count;
     
         public void add(int n) {
             lock.lock();	// 以这种方式加锁
             try {
                 count += n;
             } finally {
                 lock.unlock();	// 以这种方式解锁
             }
         }
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    1. 【必要性说明】

      • synchronized 是 Java 语言层面提供的语法,不需要考虑异常。
      • 而ReentrantLock 是 Java 代码实现的锁,要考虑异常情况,所以我们要在 finally 语句中正确释放锁。
    2. 【其他说明】

      • 和 synchronized 不同的是,ReentrantLock 可以尝试获取锁。

      • 下述代码在尝试获取锁的时候,最多等待 1 秒。如果 1 秒后仍未获取到锁,tryLock()返回 false,程序就可以做一些额外处理,而不是无限等待下去。

      • 使用 ReentrantLock 比直接使用 synchronized 更安全,线程在 tryLock() 失败的时候不会导致死锁。

        if (lock.tryLock(1TimeUnit.SECONDS)) {
            try {
                // 
            } finally {
                lock.unlock();
            }
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7

    十三、使用Condition

    Conditon 用来配合 reentrantLock 进行工作,就像 wait 与 notify 的组合

    1. 简介:Condition 与 wait/notify 类似,方法对应。

      Conditionsynchronized对应功能
      await()wait()释放当前锁,进入等待状态
      signal()notify()唤醒某个等待线程
      signalAll()notifyAll()唤醒所有等待线程

    十四、使用ReadWriteLock

    ReadWriteLock 是一种悲观锁,

    StampedLock 是一种乐观锁。

    1. 简介

      • 问题描述:synchronized 与 reentrantLock 都是【“不可重复读”锁】,但是我们还需要【“可重复读”锁】,可重复读锁能极大的提高程序运行效率。

      • 解决方式:使用 readWriteLock(可重复读锁),允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待。

        允许不允许
        不允许不允许
      • 但是,readWriteLock 是一种**【悲观锁】**。即如果有线程正在读,那么写线程需要等待读线程释放锁之后才能获取写锁(读的过程中不允许写)。

    2. 实现:分别获得读写锁。

      public class Counter {
          private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
          private final Lock rlock = rwlock.readLock();  // 获取读锁
          private final Lock wlock = rwlock.writeLock(); // 获取写锁
          private int[] counts = new int[10];
      
          public void inc(int index) {
              wlock.lock();								// 加写锁
              try {
                  counts[index] += 1;
              } finally {
                  wlock.unlock(); 				// 释放写锁
              }
          }
      
          public int[] get() {
              rlock.lock();								// 加读锁
              try {
                  return Arrays.copyOf(counts, counts.length);
              } finally {
                  rlock.unlock(); 				// 释放读锁
              }
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24

    十五、使用StampedLock

    ReadWriteLock 是一种悲观锁,

    StampedLock 是一种乐观锁。

    1. 简介:

      • JDK8,功能几乎同 ReadWriteLock,区别在于它是**【乐观锁】**,读的过程允许获取写锁。
      • 乐观锁:乐观地估计读的过程中大概率不会有写入。需要有检测机制,一旦有小概率导致的读写数据不一致,再读一遍就行。效率比悲观锁高,但是涉及到金钱的业务必须要用悲观锁。
      • 检测机制:先乐观读锁,当碰上数据不一致时开始悲观读锁。
    2. 简单实现(获取代码):

      • tryOptimisticRead():获取乐观读锁
      • readLock()::获取悲观读锁
      • validate(stamp):检查乐观读锁后是否有其他写锁发生。

      image-20220822102000455

      ​ 和ReadWriteLock相比,写入的加锁是完全一样的,不同的是读取。注意到首先我们通过tryOptimisticRead()获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过validate()去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。

      1. 使用 StampedLock 弊端:
        • 代码更加复杂。
        • 不能在一个线程中反复获取同一个锁(不可重入锁)。

    十六、使用Concurrent集合

    1. 简介:

      • Java 提供了针对 List、Map、Set、Deque 等集合的并发集合类,目的是为了保证多线程的并发读写的线程安全。
      • BlockingQueue:阻塞队列,即为会发生阻塞的队列。
      • 除此之外,
    2. Java并发集合类

      ​ 并发类容器是专门针对多线程并发设计的,使用【锁分段技术】,只对操作的位置进行同步操作,但是其他没有操作的位置其他线程仍然可以访问,提高了程序的吞吐量。

      类型non-thread-safethread-safe
      ListArrayListCopyOnWriteArrayList
      MapHashMapConcurrentHashMap
      SetHashSet/TreeSetCopyOnWriteArraySet
      QueueArrayDeque/LinkedListArrayBlockingQueue
      LinkedBlockingQueue
      DequeArrayDeque/LinkedListLinkedBlockingDeque
      • CopyOnWrite:空间换时间。写时先复制一份,写完更改原引用。CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。如果你希望写入的的数据马上就能读到,请不要使用CopyOnWrite容器。适用于读多写少的场景。
    3. 【必要性说明】

      **这些并发集合的使用与非线程安全的集合类完全相同。**因为所有的同步和加锁操作逻辑都在集合内部实现,所以外部调用者来说,只需修改类型即可。

      Map<StringString> map = new ConcurrentHashMap<>();
      
      // 在不同线程读写:
      map.put("A""1");
      map.put("B""2");
      map.get("A""1");
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      Map<StringString> map = new HashMap<>();
      
      // 迅速转换类型,无需做其他任何处理
      
      Map<StringString> map = new ConcurrentHashMap<>();
      
      • 1
      • 2
      • 3
      • 4
      • 5
    4. java.util.Collections工具类提供了一个旧的线程安全集合转换器。

      // 不推荐使用
      Map unsafeMap = new HashMap();
      Map threadSafeMap = Collections.synchronizedMap(unsafeMap);
      
      • 1
      • 2
      • 3
      • 说明:实际上就是用一个包装类包装了线程不安全的 Map,然后对所有的读写方法都使用 synchronized 进行加锁,这样获得的线程安全集合性能比 Javaconcurrent 包中提供的并发集合类性能低很多。
    5. 【小结】:使用 java.util.concurrent 包提供的线程安全并发集合类可以大大简化多线程编程。尽量使用 Java 标准库提供的并发集合,避免自己编写同步代码。


    十七、使用Atomic

    native方法,保证操作原子性。

    1. 简介:

      ​ java.util.concurrent.atomic 包提供了一组用于原子操作的封装类。

      image-20220822131045159

    2. AtomicInteger:原子整数类型,它提供的操作有:

      • 增加值并返回新值:int addAndGet(int delta)
      • 加1后返回新值:int incrementAndGet()
      • 获取当前值:int get()
      • 用 CAS 方式设置:int compareAndSet(int expect,int update)
    3. ==CAS(自旋锁)==简介:

      • 好几种翻译,分别为:Compare and Set、Compare And Swap、Compare And Exchange。

      • **作用:**确定期望值,如果相比较的两个值相等(符合期望),那么就进行更新操作,并返回true;否则什么也不做并返回 false。

        image-20220822220021426

        AtomicInteger atomicInteger = new AtomicInteger(1);
        // 期望为 1 ,若为 1 则更新至 2 ,否则不更新。
        atomicInteger.compareAndSet(12);
        
        • 1
        • 2
        • 3
      • 如果我们自己编写 CAS ,那么它大致长这个样子:

        // 此例即 AtomicInteger 自带的功能:加1后返回新值
        public int incrementAndGet(AtomicInteger var) {
            int prev, next;
            do {
                prev = var.get();
                next = prev + 1;
            } while ( ! var.compareAndSet(prev, next));
            return next;
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
    4. AtomicLong可以编写多线程安全的全局唯一ID生成器

      class IdGenerator {
          AtomicLong id = new AtomicLong(0);
      
          public long getNextId() {
              return id.incrementAndGet();
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7

    十八、使用线程池

    java.util.concurrent

    1. 简介

      ​ Java虽然支持多线程,启动一个新的线程非常简单。但是频繁创建销毁线程需要消耗大量的系统资源(线程资源、栈空间等),更好的做法是使用【线程池】。

      在这里插入图片描述

    2. Java标准库提供ExecutorService接口表示线程池,它的典型用法如下:

      • submit(Runnable runnable)接收实现 Runnable 接口的对象。
      // 创建固定大小的线程池:
      ExecutorService executor = Executors.newFixedThreadPool(3);
      // 提交任务:
      executor.submit(task1);
      executor.submit(task2);
      executor.submit(task3);
      executor.submit(task4);
      executor.submit(task5);
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
    3. 常见的 ExecutorService 接口实现类:

      • FixedThreadPool:线程数固定的线程池。
      • CacheThreadPool:线程数根据任务动态调整线程池,无上限。
      • SingleThreadExecutor:仅单线程执行的线程池。
      • ScheduleThreadPool:定时任务、循环执行。

      (创建这些线程池的方法都被封装到Executors这个类中)

      ExecutorService es = Executors.newFixedThreadPool(4);
      
      • 1
    4. 3种线程池关闭方式

      • .shutdown():等待正在执行的任务先完成,然后再关闭。
      • .shutdownNow():立刻停止正在执行的任务(强制关闭)。
      • .awaitTermination():等待指定的时间后让线程池关闭。
    5. 【必要性说明】:线程池最后需要手动关闭。

      import java.util.concurrent.*;
      
      public class Main {
          public static void main(String[] args) {
              ExecutorService es = Executors.newFixedThreadPool(4);
              for (int i = 0; i < 6; i++) {
                  es.submit(new Task());
              }
              // 关闭线程池:
              es.shutdown();
          }
      }
      
      class Task implements Runnable {
          @Override
          public void run() {
              System.out.println("a");
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
    6. 创建动态线程池

      • 原理:基于 CacheThreadPool 底层源码,创建存在 min 与 max 的线程池。

        • 线程数量:0 ~ 无限大。
        • 每个线程的最长存活时间:60秒。
        • 工作队列为:SynchronousQueue

        image-20220822162648332

        // 创建指定 min 与 max 的线程池
        int min = 4;
        int max = 10;
        ExecutorService es = new ThreadPoolExecutor(min, max,60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());
        
        • 1
        • 2
        • 3
        • 4
    7. 【工作队列】说明:常见的工作队列有以下几种,前3种用的最多。

      • ArrayBlockingQueue:列表形式。必须设置初始队列大小,有界队列,先进先出。
      • LinkedBlockingQueue:链表形式。选择设置初始队列大小,有界/无界队列,先进先出。
      • SynchronousQueue:SynchronousQueue不是一个真正的队列,而是一种在线程之间移交的机制。要将一个元素放入SynchronousQueue中, 必须有另一个线程正在等待接受这个元素。如果没有线程等待,并且线程池的当前大小小于最大值,那么ThreadPoolExecutor将创建一个线程,否则根据饱和策略,这个任务将被拒绝。使用直接移交将更高效,因为任务会直接移交给执行它的线程,而不是被首先放在队列中, 然后由工作者线程从队列中提取任务。只有当线程池是无解的或者可以拒绝任务时,SynchronousQueue才有实际价值。
      • PriorityBlockingQueue:优先级队列,有界队列,根据优先级来安排任务,任务的优先级是通过自然顺序或Comparator(如果任务实现了Comparator)来定义的。
      • DelayedWorkQueue:延迟工作队列,无界队列。
    8. 【小总结】

      • 线程池的使用使得不再需要通过以往的.start()方式启动线程,而是直接将线程置于线程池中即可。
      • CacheThreadPool有存活时间,FixedThreadPool与SingleThreadExecutor没有存活时间(超时时间)。
    9. ScheduleThreadPool【定时任务】

      • 简介:特殊的线程池,定时任务、可以反复执行。

      • 执行类型:

        • FixedRate:任务总是以固定的时间触发,而不管任务执行时间多长。
        • FixedDelay:上一次任务执行完毕后等待固定的时间间隔,再执行下一次任务。

        在这里插入图片描述

      ScheduledExecutorService s=Executors.newScheduledThreadPool(4);
      
      • 1
      // 1秒后执行任务,而且只执行一次。
      ses.schedule(new Task("one-time")1TimeUnit.SECONDS);
      
      • 1
      • 2
      // FixedRate,10秒后开始执行定时任务,每3秒执行。
      ses.scheduleWithFixedRate(new Task(),10,3, TimeUnit.SECONDS);
      
      // FixedDelay,10秒后开始执行定时任务,每3秒执行。
      ses.scheduleWithFixedDelay(new Task(),10,3, TimeUnit.SECONDS);
      
      • 1
      • 2
      • 3
      • 4
      • 5
      // lambda表达式
      ses.schedule(()->{
        
          System.out.PrintLn("hello");
        
      }1TimeUnit.SECONDS);
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
    10. 思考以下问题:

      • 在FixedRate模式下,假设每秒触发,而某次任务的执行时间超过了1s,那么会不会造成并发执行?如果此任务的任何执行时间超过其周期,则后续执行可能会延迟开始,但不会并发执行。
      • 如果任务抛出了异常,那么后续任务会不会继续执行 ?如果任务发生异常,将禁止后续任务执行。
    11. 【Timer】说明:

      Java标准库中海提供了一个java.util.Timer类,这个类也可以执行定时任务,但这个类所代表的是一个旧的体系,并不推荐,Shedule 完全可以代替 Timer。


    十九、使用Future

    Future 是 jdk8 之前的用法,在下一章有更好的做法。

    1. 简介:

      • 存在问题:我们在执行多线程任务的时候,使用线程池的确很方便(提交的任务只要实现Runnable接口),但是Runnable接口并没有返回值。如果我们需要返回值还需要编写额外的方法、变量,非常不便。

      • 解决之道:使用Callable(接口)

        • 和 Runnable 相比,只多了一个返回值。
        • 直接使用 Callable 替换 Runnable 即可。
        // call() 方法中存在我们自定义的类型返回值
        
        class Task implements Callable<String> {
            public String call() throws Exception {
                return longTimeCalculation(); 
            }
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
    2. 那么,现在的问题是如何获得异步执行的结果:

      • 简介:Callbale 通过 claa() 方法来阻塞程序并获取返回值。
      • 当然,我们知道 Callable 实现类是在例如 FixedThreadPool 的 submit() 方里面调用的,所以我们并不能直接获得返回值,必须要通过一些特殊的方式才能获得返回值,而这个特殊的方法就是使用Future
    3. 【使用说明】

      • submit() 方法会返回 Future 对象,然后利用该对象获取返回值。

      • 在主线程中的某个时刻调用 Future 实例 .get() 方法获取值。(可能存在堵塞)

        ExecutorService executor = Executors.newFixedThreadPool(4);
        
        // 定义任务:
        Callable<String> task = new Task();
        
        // 提交任务并获得Future:
        Future<String> future = executor.submit(task);
        
        // 从Future获取异步执行返回的结果:
        String result = future.get(); // 可能阻塞
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
        • 10
      • Future 是个**【泛型对象】**,它定义的方法有:

        • get():阻塞并获取返回值。
        • get(long timeout,TimeUnit unit):只阻塞某段时间,并获取返回值。
        • cancel(boolean mayInterruptIfRunning):取消当前任务。
        • isDone():判断任务是否已经完成,isDone 与 cancel 可以配合使用。
    4. 本章小结:

      • 把 Runnable 换成 Callable,并采用阻塞的方式获取返回值。
      • 如果我们不需要拥有返回值,那么这章没有意义。

    二十、使用CompletableFuture

    Java8引入,更优的 Future。

    1. 简介:

      • 对 Future 做了改进,可以传回回调对象。
      • 当异步任务万城或者发生异常时,自动调用回调对象的回调方法。
      • 即可以串行执行,也可以多个并行执行。
      • 类似于 JS 的写法。
    2. 简单实现:

      public class Main {
          public static void main(String[] args) throws Exception {
              // 创建异步执行任务:
              CompletableFuture<Double> cf = CompletableFuture.supplyAsync(Main::fetchPrice);
              
              // 如果执行成功:
              cf.thenAccept((result) -> {
                  System.out.println("price: " + result);
              });
              
              // 如果执行异常:
              cf.exceptionally((e) -> {
                  e.printStackTrace();
                  return null;
              });
              
              // 主线程不要立刻结束,
              // 否则CompletableFuture默认使用的线程池会立刻关闭。
              Thread.sleep(200);
          }
      
          /**
          * 随机生成数,当 random < 0.3 时,抛出错误。
          * 反之正常返回值
          */
          static Double fetchPrice() {
              try {
                  Thread.sleep(100);
              } catch (InterruptedException e) {
              }
              if (Math.random() < 0.3) {
                  throw new RuntimeException("fetch price failed!");
              }
              return 5 + Math.random() * 20;
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
    3. 可见,CompletableFuture 的优点是:

      • 异步任务结束时,会自动回调某个对象的方法。
      • 异步任务出错时,会自动回调某个对象的方法。
      • 主线程设置好会回调之后,不再关心一部任务的执行。

    二十一、使用ForkJoin

    1. 简介
      • Java7 引入,主要利用多核 CPU 的特性。
      • 将一个大的任务拆成多个小任务并行执行,最后再合并。
      • Arrays.parallelSort() 多核并行执行任务基于此实现。
    2. 必要性说明:此类在许多源代码中能看到,自己写的很少。

    二十二、使用ThreadLocal

    1. 简介

      • 多线程是Java实现多任务的基础,一个 Thread 实例代表一个线程。

      • web应用是典型的多任务应用,每个用户请求都是一个线程。

      • ThreadLocal 属于**【泛型】**,在一个线程中传递同一个对象,可以解决单次请求中多方法之间的数据同步问题。

        // 获取当前线程
        Thread.currentThread().getName();
        
        • 1
        • 2
    2. 使用介绍

      • 初始化:ThreadLocal 常以静态字段的形式存在。

      • 在移除之前,所有方法都可以随时获取该User实例

        static ThreadLocal<User> threadLocal = new ThreadLocal();
        
        • 1
        void processUser(user) {
            try {
                threadLocal.set(user);
                step1();
                step2();
            } finally {
                threadLocal.remove();
            }
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
        // 3次获取的 User 均是同一个对象。
        
        void step1() {
            User u = threadLocal.get();
            log();
        }
        
        void step2() {
            User u = threadLocal.get();
        }
        
        void log() {
            User u =threadLocal.get();
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
        • 10
        • 11
        • 12
        • 13
        • 14
    3. ThreadLocal是什么?

      • ThreadLocal 是提供给线程内使用的局部变量。

      • ThreadLocal 实际上相当于全局性的 Map,并且总是以自身作为键。

      • 每一个线程都有属于自己的 ThreadLcoal 对象,它会开辟一份线程专属空间,里面存放一些属于该线程的数据。

      • 在 Web 应用中使用 ThreadLocal 时,要注意退出时清空数据(可以利用 finally 语句)。

        // 清空 threadLocal 数据
        threadLocal.remove();
        
        • 1
        • 2
    4. 【必要性说明】

      • 只要线程处于活跃状态,数据就存在。
      • 线程消失时,本地数据就会被垃圾回收(除非数据还存在其他引用)。
      • ThreadLocal里面含有内部类 ThreadLocalMap,内部类里又含有 Entry 数组,主要的工作就是负责维护本地变量值。
    5. 我们可以使用 AutoCloseable 接口来封装 ThreadLcoal ,从而使得它可以在try(){}语句中被自动的关闭。

      public class UserContext implements AutoCloseable {
      
          static final ThreadLocal<String> ctx = 
            new ThreadLocal<>();
      
          public UserContext(String user) {
              ctx.set(user);
          }
      
          public static String currentUser() {
              return ctx.get();
          }
      
          // 结合 try(){},该方法会在最后自动调用。
          @Override
          public void close() {
              ctx.remove();
         	 }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      try (var ctx = new UserContext("Bob")) {
          String currentUser = UserContext.currentUser();
      } 
      
      • 1
      • 2
      • 3

    二十三、番外篇

    1. 线程池的优点是避免了多线程相互竞争资源而使服务器内存耗尽或程序运行失败的情景。
    2. 默认情况下,创建完线程池后并不会立即创建线程,而是等到有任务提交时才会创建线程来进行处理(懒加载)。当线程数小于核心线程数时,每提交一个任务就创建一个线程来执行,即使当前有线程处于空闲状态,直到当前线程数达到核心线程数。
    3. 当前线程数达到核心线程数时,如果这个时候还提交任务,这些任务会被放到工作队列里,等到线程处理完了手头的任务后,会来工作队列中取任务处理。
    4. 如果某个线程的控线时间超过了 keepAliveTime,那么将被标记为可回收的,并且当前线程池的当前大小超过了核心线程数时,这个线程将被终止。(故只有核心数的线程会被标记为保存,处于core ~ max 中的线程当不被使用时会被kill)
    5. 如果新请求的到达速率超过了线程池的处理速率,那么新到来的请求将累积起来。在线程池中,这些请求会在一个由 Executor 管理的 Runnable 队列中等待,而不会去竞争 CPU 资源。
    6. 当前线程数达到最大线程数并且队列也满了,如果这个时候还提交任务,则会触发饱和策略,JVM 会自动抛弃一部分任务(可能会造成危机)。
    7. 【饱和策略】说明
      • AbortPolicy:中止策略。抛出未检查的RejectedExecutionException,调用者可以捕获这个异常,然后根据需求编写自己的处理代码。(默认)
      • DiscardPolicy:抛弃策略。当新提交的任务无法保存到队列中等待执行时,该策略会悄悄抛弃该任务。
      • DiscardOldestPolicy:抛弃最旧的策略。当新提交的任务无法保存到队列中等待执行时,则会抛弃下一个将被执行的任务,然后尝试重新提交新的任务。(如果工作队列是一个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最高的任务,因此最好不要将“抛弃最旧的”策略和优先级队列放在一起使用)。
      • CallerRunsPolicy:调用者运行策略。该策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者(调用线程池执行任务的主线程),从而降低新任务的流程。它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的线程中执行该任务。当线程池的所有线程都被占用,并且工作队列被填满后,下一个任务会在调用execute时在主线程中执行(调用线程池执行任务的主线程)。由于执行任务需要一定时间,因此主线程至少在一段时间内不能提交任务,从而使得工作者线程有时间来处理完正在执行的任务。在这期间,主线程不会调用accept,因此到达的请求将被保存在TCP层的队列中。如果持续过载,那么TCP层将最终发现它的请求队列被填满,因此同样会开始抛弃请求。当服务器过载后,这种过载情况会逐渐向外蔓延开来——从线程池到工作队列到应用程序再到TCP层,最终达到客户端,导致服务器在高负载下实现一种平缓的性能降低。
    8. 【线程工厂】说明:,有以下2种(可自定义),一般使用默认的即可。
      • DefaultThreadFactory:默认线程工厂,创建一个新的、非守护的线程,并且不包含特殊的配置信息。
      • PrivilegedThreadFactory:通过这种方式创建出来的线程,将与创建privilegedThreadFactory的线程拥有相同的访问权限、 AccessControlContext、ContextClassLoader。如果不使用privilegedThreadFactory, 线程池创建的线程将从在需要新线程时调用execute或submit的客户程序中继承访问权限。
      • 自定义线程工厂:可以自己实现ThreadFactory接口来定制自己的线程工厂方法。
  • 相关阅读:
    【PCL库+ubuntu+C++】 2.使用PCL实现对点云的变换
    MIT6.5830 Lab0-Go tutorial实验记录(四)
    基于python+PHP+MySQL的大学生二手闲置商品交易系统
    Java+SSM+JSP实现高校学生健康档案管理系统
    如何编写 Pipeline 脚本
    高级深入--day30
    zabbix mysql监控项
    Java 基础(继承、接口、抽象)
    示波器探头对测量电容负荷有影响吗?
    【python笔记】第十三节 常用模块
  • 原文地址:https://blog.csdn.net/qq_35760825/article/details/126474562