Java 多线程系列Ⅳ

一、设计模式(单例模式+工厂模式)

在软件开发过程中,会遇见很多的问题场景,对于经常遇到的问题场景,一些大佬总结出一些针对特有场景的固有套路,按照这些套路,将帮助我们将问题简单化,条理清楚的解决问题,这也是设计模式的初衷;

设计模式(Design Pattern) 是软件开发过程中面临通用问题的解决方案。这些解决方案是经过长时间试验和错误总结出来的,它们被反复使用,大多数人知晓,并且经过分类编目和代码设计经验的总结。使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性。

首先,我们讲讲多线程中的设计模式:单例模式

1、单例模式

单例模式是一种常见的设计模式,单例模式能保证某个类在程序中只存在唯一一份实例, 而不会创建出多个实例。,并提供一个全局访问点来访问该实例。这种模式在需要频繁创建和销毁同一实例的场景中非常有用,例如配置管理、日志记录等。

Java中实现单例模式的方式有很多种,但他们本质上都是一样的,这里主要介绍两种,即饿汉模式懒汉模式

首先,饿汉和懒汉有什么区别?

  1. 初始化的时间:饿汉式和懒汉式的区别在于初始化的时间。饿汉式在类加载时就会创建实例,因此是立即初始化的,而懒汉式则是在类加载后,首次调用该类时才创建实例。
  2. 线程安全性:饿汉式由于在类加载时就已经创建好实例,因此在多线程环境下是线程安全的,无需额外的同步措施。而懒汉式则需要在调用getInstance方法时进行同步,以防止多个线程同时创建多个实例。
  3. 性能开销:懒汉式的实现方式会延迟实例的创建,只有在需要时才创建实例,因此可能会增加第一次调用的时间。而饿汉式由于提前创建了实例,因此在第一次调用时无需额外的开销。
  4. 内存占用:懒汉式由于是延迟加载,因此不会占用内存空间,只有当需要时才会创建实例。而饿汉式则会在类加载时就已经创建好实例,会占用一定的内存空间。

饿汉式

java 复制代码
public class Singleton {  
    // 在类加载时就实例化对象  
    private static final Singleton instance = new Singleton();  
  
    // 构造函数私有化,防止在其他类中创建此类的实例  
    private Singleton() {}  
  
    // 提供公开的静态方法 getInstance 来获取该单例对象  
    public static Singleton getInstance() {  
        return instance;  
    }  
}

说明:Singleton类在加载时就会创建其实例,因此无论何时调用Singleton.getInstance()方法,都会返回已经创建好的单例对象。由于构造函数被私有化,外部无法创建新的Singleton实例,只能通过getInstance()方法获取已经存在的实例。这种方式简单易懂,适用于线程安全和性能要求较高的场景。

懒汉式

简单实现:

java 复制代码
public class Singleton {  
    // 声明一个静态的实例对象,初始化为null  
    private static Singleton instance = null;  
  
    // 构造函数私有化,防止在其他类中创建此类的实例  
    private Singleton() {}  
  
    // 提供一个公共的静态方法 getInstance 来获取该单例对象  
    public static Singleton getInstance() {  
        if (instance == null) {  
            synchronized (Singleton.class) {  
                if (instance == null) {  
                    instance = new Singleton();  
                }  
            }  
        }  
        return instance;  
    }  
}

说明 :Singleton类在加载时不会创建实例,而是在调用Singleton.getInstance()方法时才创建实例。这种方式可以减少内存占用,适用于内存资源较紧的场景。同时,由于实例的创建是延迟的,因此可以减少不必要的初始化操作。但是,由于存在线程不安全的问题,需要使用同步措施来保证线程安全性。常见的同步方式包括使用synchronized关键字、双重检查锁定等。

单例模式线程安全问题

  1. 饿汉模式:本身是线程安全的,只读操作

  2. 懒汉模式:线程不安全,有读有写

    (1)加锁,把 if 和 new 变成原子操作

    (2)双层检查锁两个if ,减少不必要的加锁操作

    (3)使用 volatile 禁止指令重排序,保证后续线程肯定拿到的是完整对象。

2、工厂模式

工厂模式最主要解决的问题就是创建者和调用者的耦合,那么代码层面其实就是取消对new的使用。是一种设计模式,它提供了一种创建对象的接口,使得创建对象的具体逻辑与客户端代码分离。工厂模式通过将对象的创建逻辑封装在工厂类中,使得客户端代码只需要与工厂类进行交互,而不需要直接创建对象。

工厂模式的优点在于可以将对象的创建逻辑封装在工厂类中,使得客户端代码更加简洁和可维护。此外,工厂模式还可以实现对象的延迟加载和缓存,提高应用程序的性能。

java 复制代码
	public interface UserDao {  

	    public User getUserById(int userId);  

	}  

	public class H2UserDao implements UserDao {  

	    public User getUserById(int userId) {  
	        // 实现具体的逻辑  
	    }  

	}  

	public class MyBatisUserDao implements UserDao {  

	    public User getUserById(int userId) {  
	        // 实现具体的逻辑  
	    }  

	}  

	public class UserDaoFactory {  

	    public static UserDao createUserDao(String type) {  

	        if (type.equalsIgnoreCase("h2")) {  

	            return new H2UserDao();  

	        } else if (type.equalsIgnoreCase("mybatis")) {  

	            return new MyBatisUserDao();  

	        } else {  

	            throw new IllegalArgumentException("Invalid type: " + type);  

	        }  

	    }  

	}

在上面的一段代码中,UserDao是一个接口 ,定义了获取用户信息的操作。H2UserDaoMyBatisUserDao 分别实现了UserDao接口,并实现了具体的逻辑。UserDaoFactory是一个工厂类,它负责根据传入的类型创建相应的UserDao对象。客户端代码只需要调用UserDaoFactory.createUserDao()方法,就可以获得相应的UserDao对象,而不需要直接创建对象。

通过上诉代码实现,我们可以很轻松的实现切换不同的数据库连接方式或者ORM框架。并且我们还在这个工厂类中添加了缓存逻辑,可以进一步提高应用程序的性能。

二、阻塞式队列

一般的队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前的任务了,阻塞队列通过阻塞可以保留住当前想要继续入队的任务。

阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入wait状态,释放cpu资源。

阻塞队列自带阻塞和唤醒的功能,不需要额外处理,无任务执行时,线程池利用阻塞队列的take方法挂起,从而维持核心线程的存活、不至于一直占用cpu资源

阻塞式队列(Blocking Queue)具有以下特性:

  1. 先进先出(FIFO):阻塞式队列按照先进先出的原则对元素进行排序。也就是说,最早进入队列的元素会最先被移出队列。
  2. 阻塞操作:当队列为空时,从队列中获取元素的操作会被阻塞,直到队列中有新的元素;当队列已满时,向队列中添加元素的操作会被阻塞,直到队列中有元素被移除。
  3. 唤醒操作:当队列不为空时,可以从队列中获取元素;当队列有剩余空间时,可以向队列中添加元素。
  4. 多线程支持:阻塞式队列通常用于多线程编程,可以有效地协调生产者和消费者的同步问题。在多线程环境中,生产者线程和消费者线程可以同时操作阻塞式队列,实现数据的共享和同步。
  5. 线程安全:阻塞式队列是线程安全的,多个线程可以同时对队列进行操作,而不会导致数据不一致或者其他并发问题。
  6. 可缓存多个元素:阻塞式队列可以缓存多个元素,当队列已满时,如果还有新的元素需要添加到队列中,可以先将已有的元素保存在队列中,等到队列中有元素被移除时再将保存的元素添加到队列中。

1、生产者消费者模型

生产者和消费者之间,如何实现数据同步和数据交互,需要用到一个交易场所,这个交易场所就是"阻塞队列"。

生产者和消费者之间都是不直接通讯,而是通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里拿。

生活中的一个例子:超市购物;有一个超市,这个超市有一个收银台(生产者)和一个顾客等待队列(消费者),以及一个存放购物篮的区域(阻塞队列)。

  1. 生产者:收银台

生产者是超市中的收银台。每个收银员负责扫描商品并处理付款。这就像生产者线程在生成数据并添加到队列中。如果队列已满(即购物篮已满),收银员将等待直到队列有空间。

  1. 阻塞队列:购物篮

购物篮在这里起到了阻塞队列的作用。当所有的购物篮都已满时,新的顾客(即新的消费者)会被阻塞,直到有购物篮可用。同时,如果所有的顾客都完成了购物并离开了超市,而购物篮中仍有商品未被拿走,那么这些商品将留在购物篮中,直到有新的顾客进入超市并取走它们。

  1. 消费者:顾客

顾客在超市中就是消费者。他们从队列中获取购物篮,选择他们需要的商品,然后将购物篮归还给收银台。如果所有的购物篮都已经被使用,那么新的顾客将被阻塞,直到有购物篮可用。

2、阻塞对列在生产者消费者之间的作用

阻塞队列在生产者和消费者之间起到了一个桥梁的作用。它既能够让生产者生产的商品(数据)存储起来,又能让消费者购买的商品(数据)被取走。

具体来说,当生产者生产商品后,会将商品放入阻塞队列中。如果队列已满,生产者会等待直到队列有空间。而消费者则从队列中取出商品进行消费。如果队列为空,消费者会被阻塞直到队列中有新的商品。

阻塞队列在这里起到了缓冲区的作用,它有效地衔接了生产者和消费者之间的速度差异,提供了一种协调和安全的数据交互方式。平衡了生产者和消费者的处理能力,起到削峰填谷的作用。

3、用标准库阻塞队列实现生产者消费者模型

  1. BlockingQueue 是一个接口,真正实现的类是 LinkedBlockingQueue。
  2. put 方法用于阻塞式的入队列,take 用于阻塞式的出队列。
  3. BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性。
java 复制代码
import java.util.concurrent.ArrayBlockingQueue;  
import java.util.concurrent.BlockingQueue;  
  
public class ProducerConsumerExample {  
    public static void main(String[] args) {  
        // 创建一个容量为10的阻塞队列  
        BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);  
  
        // 创建一个生产者线程和一个消费者线程  
        Thread producer = new Thread(new Producer(queue));  
        Thread consumer = new Thread(new Consumer(queue));  
  
        // 启动线程  
        producer.start();  
        consumer.start();  
    }  
}  
  
class Producer implements Runnable {  
    private BlockingQueue<Integer> queue;  
  
    public Producer(BlockingQueue<Integer> queue) {  
        this.queue = queue;  
    }  
  
    @Override  
    public void run() {  
        try {  
            for (int i = 0; i < 100; i++) {  
                queue.put(i); // 添加元素到队列中,如果队列已满则会被阻塞  
                System.out.println("Producer produced " + i);  
                Thread.sleep(100); // 模拟生产数据的耗时操作  
            }  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
    }  
}  
  
class Consumer implements Runnable {  
    private BlockingQueue<Integer> queue;  
  
    public Consumer(BlockingQueue<Integer> queue) {  
        this.queue = queue;  
    }  
  
    @Override  
    public void run() {  
        try {  
            while (true) {  
                Integer i = queue.take(); // 从队列中获取元素,如果队列为空则会被阻塞  
                System.out.println("Consumer consumed " + i);  
                Thread.sleep(100); // 模拟处理数据的耗时操作  
            }  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
    }  
}

三、定时器

在软件开发中,定时器是一种用于在特定时间执行某项任务的功能。它可以在指定的时间间隔后触发一个事件,或者在特定的时间点执行某个操作。定时器在软件开发中有很多应用,例如在操作系统中实现任务调度、在应用程序中定时执行某些操作、在网络通信中定时发送数据等。

1、标准库中的定时器

  1. 标准库中提供了一个 Timer 类,表示定时器。
  2. Timer 类的核心方法为 schedule 。用来为定时器安排任务。
  3. schedule 包含两个参数,第一个参数指定即将要执行的任务TimerTask, 第二个参数指定多长时间之后执行 (单位为毫秒)。

简单的Timer定时器:

java 复制代码
import java.util.Timer;  
import java.util.TimerTask;  
  
public class SimpleTimerTaskExample {  
  
    public static void main(String[] args) {  
        // 创建一个Timer对象  
        Timer timer = new Timer();  
  
        // 创建一个TimerTask对象,这里我们使用匿名内部类的方式创建  
        TimerTask task = new TimerTask() {  
            @Override  
            public void run() {  
                // 这里是定时任务的具体逻辑,这里我们简单打印一条消息  
                System.out.println("执行定时任务");  
            }  
        };  
  
        // 定时器任务每隔1秒执行一次,这里设置的是1000毫秒,即1秒  
        timer.schedule(task, 0, 1000);  
    }  
}

该任务每隔1秒(1000毫秒)就会执行一次。

2、模拟实现定时器

使用优先队列模拟实现一个定时器是一个很好的选择,因为优先队列可以让我们根据元素的优先级对它们进行排序。在这个定时器的实现中,我们可以将延迟时间作为元素的优先级。每次我们从队列中取出具有最小延迟时间的元素,然后执行它。

最终实现代码:

java 复制代码
import java.util.PriorityQueue;  
import java.util.concurrent.TimeUnit;  
  
public class Timer {  
    private final PriorityQueue<Task> queue;  
  
    public Timer() {  
        this.queue = new PriorityQueue<>((t1, t2) -> Long.compare(t2.getDelay(), t1.getDelay()));  
    }  
  
    public void schedule(Task task) {  
        queue.add(task);  
    }  
  
    public void run() {  
        while (true) {  
            Task task = queue.poll();  
            if (task == null) break;  
  
            long delay = task.getDelay();  
            try {  
                TimeUnit.SECONDS.sleep(delay); // 等待指定的延迟时间  
                System.out.println("任务执行"); // 执行任务,这里仅仅是打印一条消息,你可以替换为实际的任务执行逻辑  
            } catch (InterruptedException e) {  
                Thread.currentThread().interrupt();   
            }  
        }  
    }  
}  
  
class Task {  
    private final long delay;  
    private final Runnable task;  
  
    public Task(long delay, Runnable task) {  
        this.delay = delay;  
        this.task = task;  
    }  
  
    public long getDelay() {  
        return delay;  
    }  
  
    public void run() {  
        task.run();  
    }  
}

代码说明

  1. Timer类:

    • Timer类负责定时任务的调度和执行。它包含一个成员变量queue,是一个优先队列,用于存储待执行的任务。队列中的任务按照延迟时间进行排序,延迟时间越短的任务优先级越高。
    • 构造函数中初始化了优先队列queue,并定义了一个比较器函数,用于比较两个任务的延迟时间。这里使用Lambda表达式实现了Comparator接口的比较方法。
    • schedule(Task task)方法用于向队列中添加一个新的任务。它将任务对象作为参数传入,并将任务添加到队列中。
    • run()方法是定时器的主要执行逻辑。它在一个无限循环中不断地从队列中取出具有最小延迟时间的任务,然后等待该延迟时间后执行任务。如果任务被中断,则会捕获InterruptedException异常并重新开始循环。执行任务的逻辑可以通过替换System.out.println语句来实现。
  2. Task类:

    • Task类表示一个待执行的任务。它包含两个成员变量:delay表示任务的延迟时间,task是一个Runnable对象,表示实际的任务逻辑。
    • 构造函数用于初始化任务的延迟时间和Runnable对象。
    • getDelay()方法返回任务的延迟时间。
    • run()方法负责执行实际的Runnable任务。当Timer类调用task.run()时,就会执行这里定义的Runnable对象的run()方法。

为什么不使用 PriorityBlockingQueue?

  1. 实现简单性:PriorityQueue的使用相对简单,它是一个非阻塞队列,不支持多线程之间的协作。而PriorityBlockingQueue则是一个阻塞队列,可以用于多线程环境,如果多个线程试图从队列中取出元素,将会导致阻塞,直到队列中有元素可用。
  2. 适用性:由于这段代码实现了一个简单的定时器,它并不涉及多线程间的协作,因此使用PriorityQueue更为合适。使用PriorityBlockingQueue可能会增加不必要的复杂性,并且在这个场景中可能并不需要它的特性。
  3. 性能:PriorityQueuePriorityBlockingQueue在性能上可能有一些差异。非阻塞的PriorityQueue可能在某些情况下提供更好的性能,尤其是在处理大量任务时。
  4. 由于它自带阻塞,在加上定时器需要 wait 本身需要加锁,因此很容易就形成了死锁

四、线程池

1、线程池概述

线程池是一种用于优化线程管理的技术,它可以在应用程序启动时预先创建一组线程并保存在内存中,以避免频繁地创建和销毁线程。线程池通过控制并发线程的数量来提高系统的效率和性能,它能够限制线程的数量,以便在执行任务时避免过度创建线程。

为什么在线程池里取线程比直接创建线程更高效?

从线程池中拿线程是用户级别操作,从系统创建线程,涉及用户态和核心态的切换,一旦涉及切换,效率大打折扣。

2、ThreadPoolExecutor 参数

参数 作用
corePoolSize 核心线程池大小,核心线程数,也就是正常情况下创建工作的线程数,这些线程创建后并不会消除,而是一种常驻线程
maximumPoolSize 最大线程池大小,最大线程数,它与核心线程数相对应,表示最大允许被创建的线程数,比如当前任务较多,将核心线程数都用完了,还无法满足需求时,此时就会创建新的线程,但是线程池内线程总数不会超过最大线程数
keepAliveTime 线程池中超过 corePoolSize 数目的空闲线程最大存活时间;超出核心线程数之外的线程的空闲存活时间,也就是核心线程不会消除,但是超出核心线程数的部分线程如果空闲一定的时间则会被消除,我们可以通过 setKeepA7iveTime来设置空闲时间
TimeUnit keepAliveTime 时间单位
workQueue 阻塞任务队列,用来存放待执行的任务,假设我们现在核心线程都已被使用,还有任务进来则全部放入队列,直到整个队列被放满但任务还再持续进入则会开始创建新的线程
threadFactory 新建线程工厂,是一个线程工厂,用来生产线程执行任务。我们可以选择使用默认的创建工厂,产生的线程都在同一个组内,拥有相同的优先级,且都不是守护线程。当然我们也可以选择自定义线程工厂,一般我们会根据业务来制定不同的线程工厂
RejectedExecutionHandler 拒绝策略。当提交任务数超过 maxmumPoolSize+workQueue 之和时,任务会交给RejectedExecutionHandler 来处理,任务拒绝策略,有两种情况,第一种是当我们调用shutdown 等方法关闭线程池后,这时候即使线程池内部还有没执行完的任务正在执行,但是由于线程池已经关闭,我们再继续想线程池提交任务就会遭到拒绝。另一种情况就是当达到最大线程数,线程池已经没有能力继续处理新提交的任务时,这是也就拒绝

3、RejectedExecutionHandler 拒绝策略

线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列也已经排满了,再也塞不下新任务了。这时候我们就需要拒绝策略机制合理的处理这个问题。

JDK 内置的拒绝策略如下:

AbortPolicy: 直接抛出异常,阻止系统正常运行。可以根据业务逻辑选择重试或者放弃提交等策略。

CallerRunsPolicy : 只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。

不会造成任务丢失,同时减缓提交任务的速度,给执行任务缓冲时间。

DiscardOldestPolicy : 丢弃最老的一个请求,也就是即将被执行的任务,并尝试再次提交当前任务。

DiscardPolicy : 该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这是最好的一种方案。

4、模拟实现线程池

java 复制代码
public class MyThreadPool {
    // 管理任务的阻塞队列(本身就是多线程安全)
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue();
	
	// 添加任务方法
    public void submit(Runnable runnable) throws InterruptedException {
        queue.put(runnable);
    }

    // 实现一个固定线程个数的线程池
    public MyThreadPool(int n) {
        for (int i = 0; i < n; i++) {
            Thread t = new Thread(()->{
                while (true) {
                    try {
                        Runnable runnable = queue.take();
                        runnable.run();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            // 启动线程
            t.start();
        }
    }
}

5、创建线程池的两种方式

  1. 通过ThreadPoolExecutor构造函数来创建(推荐)。
  2. 通过 Executor 框架的工具类 Executors 来创建。

6、拓展:实际开发中应该如何确定线程池中线程的数量?

  • CPU 密集型(n+1)

CPU 密集的意思是该任务需要大量的运算,而没有阻塞,CPU 一直全速运行。

CPU 密集型任务尽可能的少的线程数量,一般为 CPU 核数 + 1 个线程的线程池。

  • IO 密集型(2*n)

由于 IO 密集型任务线程并不是一直在执行任务,可以多分配一点线程数,如 CPU * 2

也可以使用公式:CPU 核心数 *(1+平均等待时间/平均工作时间)。

相关推荐
豆角焖肉9 分钟前
Maven进阶与搭建私服
java·maven
大不点wow20 分钟前
Java序列化与反序列化:让对象走出JVM
java·开发语言·jvm
止语Lab24 分钟前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活24 分钟前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
噢,我明白了25 分钟前
Java中日期和字符串的处理
java·开发语言·日期
程序员天天困29 分钟前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn29 分钟前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
dkbnull32 分钟前
Spring Boot请求处理组件对比详解
java·spring boot
jun_bai33 分钟前
Orthanc服务器使用java上传dicom影像文件
java·运维·服务器
顺风尿一寸35 分钟前
记一次 Spring AOP 与定时任务引发的死锁排查
java