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+平均等待时间/平均工作时间)。

相关推荐
字节流动1 小时前
Android Java 版本的 MSAA OpenGL ES 多重采样
android·java·opengles
玉红7771 小时前
R语言的数据类型
开发语言·后端·golang
呜呼~225142 小时前
前后端数据交互
java·vue.js·spring boot·前端框架·intellij-idea·交互·css3
飞的肖2 小时前
从测试服务器手动热部署到生产环境的实现
java·服务器·系统架构
周伯通*2 小时前
策略模式以及优化
java·前端·策略模式
两点王爷2 小时前
Java读取csv文件内容,保存到sqlite数据库中
java·数据库·sqlite·csv
lvbu_2024war013 小时前
MATLAB语言的网络编程
开发语言·后端·golang
问道飞鱼3 小时前
【Springboot知识】Springboot进阶-实现CAS完整流程
java·spring boot·后端·cas
抓哇小菜鸡3 小时前
WebSocket
java·websocket
single5943 小时前
【c++笔试强训】(第四十五篇)
java·开发语言·数据结构·c++·算法