当前位置: 代码网 > it编程>编程语言>Java > Java多线程四个案例精讲(含代码)

Java多线程四个案例精讲(含代码)

2026年09月29日 • Java •我要评论
案例一、单例模式1.1 单例模式是什么单例模式是一种设计模式,设计模式是类似于棋谱、菜谱这类入门指南,相当于一种固定的套路/模板/公式,按照这个设计模式会方便很多。单例模式是保证系统实例唯一性的重要手

案例一、单例模式

1.1 单例模式是什么

单例模式是一种设计模式,设计模式是类似于棋谱、菜谱这类入门指南,相当于一种固定的套路/模板/公式,按照这个设计模式会方便很多。

在这里插入图片描述

单例模式是保证系统实例唯一性的重要手段。

单例模式首先通过将类的实例化方法(即构造方法)私有化来防止程序通过其他方式创建该类的实例,然后通过提供一个全局唯一获取该类实例的方法(即getinstance方法)帮助用户获取类的实例,用户只需也只能通过调用该方法获取类的实例。

单例模式的设计保证了一个类在整个系统中同一时刻只有一个实例存在,主要被用于一个全局类的对象在多个地方被使用并且对象的状态是全局变化的场景下。同时,单例模式为系统资源的优化提供了很好的思路,频繁创建和销毁对象都会增加系统的资源消耗,而单例模式保障了整个系统只有一个对象能被使用,很好地节约了资源。

1.2 饿汉模式

饿汉模式指在类中直接定义全局的静态对象的实例并初始化,然后提供一个方法(getinstance方法)获取该实例对象。通过饿汉模式构造单例模式的代码如下:

class singletonhungry {
	// 只能存在这一个实例
	private static singletonhungry instance = new singletonhungry();

	// get方法
	public static singletonhungry getinstance() {
		return instance;
	}
	
	// 构造方法
	private singletonhungry() {
		// ......
	}
}

因为程序一启动就会创建实例并初始化,迫切需要使用实例,就像饿了很久的人见到美食迫切敞开肚子开吃一样,因此称为饿汉模式。

在这里插入图片描述

对饿汉模式分析,我们可以发现get方法只有一个return语句,这意味着只涉及到读操作,不涉及到修改变量的操作,因此一般不会存在线程安全问题。

在这里插入图片描述

1.3 懒汉模式

懒汉模式和饿汉模式是相对的,如果说饿汉模式是尽可能早地创建实例,那么懒汉模式就是尽可能晚地创建实例,在某些情况下也可能不创建实例(延迟创建)。

举个例子,用户在浏览网页的时候不会在短时间内看的了太多的内容,就只加载第一页的内容,这样既不会对内存有很大压力,也可以提高效率。当用户翻页,再加载后续的内容,这样动态地加载。

懒汉模式和饿汉模式的最大不同在于,懒汉模式在类中定义了单例但是并未实例化,实例化的过程是在获取单例对象的方法中实现的,也就是说,在第一次调用懒汉模式时,该对象一定为空,然后去实例化对象并赋值,这样下次就能直接获取对象了;而饿汉模式是在定义单例对象的同时将其实例化的,直接使用便可。

1.3.1 单线程环境下的懒汉模式

class singletonlazy {
	// 先将实例置空
	private static singletonlazy instance = null;

	// get方法
	public static singletonlazy getinstance() {
		// 判断是否第一次调用get方法
		if (instance == null) {
			instance = new singletonlazy();
		}
		return instance;
	}
	
	// 构造方法
	private singletonlazy() {
		// ......
	}
}

在以上的代码中,创建实例需要在第一次调用get方法的时候,后续如果再调用get方法,instance不为空就不会再new一个实例了。

1.3.2 多线程环境下的懒汉模式

我们来分析一下单线程版本的懒汉模式是否存在线程安全问题呢?

线程安全问题无非三个方面:

  • 原子性
  • 可见性
  • 有序性

在这里插入图片描述

原子性get方法中的 “if条件判断语句” 和 “new创建实例语句” 这两条语句必须要是原子性的才不会出现bug,否则会出现问题:

  • if条件判断语句为步骤一,new创建实例语句为步骤二
  • 当t1线程执行了步骤一时,线程调度到t2线程了
  • 此时t2线程也执行了步骤一,线程调度回t1线程
  • t1线程继续执行步骤二创建了一个实例,接着线程调度到t2
  • t2线程也继续执行步骤二,也创建了一个实例
  • 创建了两个实例,不符合预期,出现线程安全问题

我们对get方法加个锁,强行绑定if语句和new语句,此时的synchronized的锁对象是类对象 singletonlazy.class:

class singletonlazy {
	// 先将实例置空
	private static singletonlazy instance = null;

	// get方法:加锁绑定if语句和new语句 
	public static synchronized singletonlazy getinstance() {
		// 判断是否第一次调用get方法
		if (instance == null) {
			instance = new singletonlazy();
		}
		return instance;
	}
	
	// 构造方法
	private singletonlazy() {
		// ......
	}
}

或者直接在if语句外面套一层synchronized加锁语句:

class singletonlazy {
	// 先将实例置空
	private static singletonlazy instance = null;
	private static object locker = new object();

	// get方法
	public static singletonlazy() {
		// 加锁绑定if语句和new语句
		synchronized (locker) {
			// 判断是否第一次调用get方法
			if (instance == null) {
				instance = new singletonlazy();
			}
		}
		return instance;
	}
	
	// 构造方法
	private singletonlazy() {
		// ......
	}
}

但是,程序运行并调用get方法后,每一次都会加锁,都会导致线程阻塞,这也会降低程序运行的效率。

为了提高程序的效率,我们可以再加一层if语句:

class singletonlazy {
	// 先将实例置空
	private static singletonlazy instance = null;
	private static object locker = new object();

	// get方法
	public static singletonlazy() {
		if (instance == null) {	// 判断是否需要加锁
			// 加锁绑定if语句和new语句
			synchronized (locker) {
				// 判断是否第一次调用get方法
				if (instance == null) {
					instance = new singletonlazy();
				}
			}
		}
		return instance;
	}
	
	// 构造方法
	private singletonlazy() {
		// ......
	}
}

这样一来,原子性的问题就解决了~

可见性和有序性在代码中,new语句通常对应着三条指令:

  • 申请内存空间
  • 在内存空间上创建实例(初始化)
  • 将内存空间的首地址赋值给引用变量

    在这里插入图片描述

    一般来说,以上三条指令的执行顺序是 1 -> 2 -> 3,但是由于编译器优化,可能出现的执行顺序是:1 -> 3 -> 2:
  • 当线程执行1之后,就执行3
  • 接下来可能通过引用变量调用方法,但是此时并没有对实例进行初始化,就会出现问题

volatile关键字具备两种特性:一种是保证该变量对所有线程可见,在一个线程修改了变量的值后,新的值对于其他线程是可以立即获取的;一种是volatile禁止指令重排,即volatile变量不会被缓存在寄存器中或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

因此我们将实例用 volatile关键字修饰即可。

这样一来,可见性和有序性也解决了,这就是多线程环境下的懒汉模式的代码,这个处理线程安全问题的方法是双检锁,既可以提高程序效率减少因加锁产生的额外开销,又可以通过volatile保证禁止指令重排序。

代码如下:

class singletonlazy {
	// 用volatile关键字修饰实例
    private static volatile singletonlazy instance = null;
    private static object locker = new object();

    // get方法
    public static singletonlazy getinstance() {
        if (instance == null) {
            synchronized (locker) {
                if (instance == null) {
                    instance = new singletonlazy();
                }
            }
        }
        return instance;
    }

    // 构造方法
    private singletonlazy() {

    }
}

面试题:饿汉模式和懒汉模式的核心区别是什么?各自的优缺点?如何在多线程环境中保证懒汉模式的安全?

  • 在初始化时机方面:饿汉模式在类加载时立即创建单例实例,与线程是否调用无关;懒汉模式在首次调用getinstance方法时才创建单例实例,实现延迟加载。
  • 资源开销方面:饿汉模式的优点是初始化简单且无延迟加载问题、静态变量初始化由jvm保证,开销可控,缺点是资源可能会浪费,即使实例未被使用也会占用内存和cpu资源;懒汉模式的优点是资源高效,只在需要时才创建实例减少内存和启动开销,缺点是实现较复杂需要额外同步机制防止线程安全问题,增加了代码的复杂度。
  • 线程安全性方面:饿汉模式是天然线程安全的,静态变量instance在类加载期间由jvm初始化整个过程是原子的,无需额外同步;懒汉模式是非线程安全的,在原始实现下多个线程同时访问getinstance方法可能会导致创建多个实例,需要处理线程安全问题。
  • 多线程中保证懒汉模式安全的方法:双检锁 + volatile修饰实例。只有在首次调用getinstance方法时才会进行加锁操作,减少同步开销;利用volatile关键字禁止指令重排序,防止“部分初始化的对象”被访问

案例二、阻塞队列

2.1 阻塞队列是什么

阻塞队列是一种特殊的队列,也遵循“先进先出”的原则。

阻塞队列是一种线程安全的数据结构,具体体现在:

  • 当队列为空的时候,如果想要出队列就会阻塞,直到其他线程往队列中添加元素为止
  • 当队列为满的时候,如果想要入队列就会阻塞,直到其他线程从队列中取出元素为止

2.2 生产者消费者模型

阻塞队列的一个常用场景就是生产者消费者模型——是一个非常典型的开发模型。

生产者消费者模型就是通过一个容器,将生产者和消费者的耦合度降低下来,这样一来,生产者和消费者不会直接进行交互。为什么要降低生产者和消费者之间的耦合度呢?通过以下例子就明白了。

比如:厨师做菜(生产者),服务员将菜品给顾客(消费者),并且厨师做出菜后必须亲自递交给上菜的服务员。

  • 当厨师做菜速度太慢,而服务员那边的订单数量激增,那只能等厨师做出菜来才可以让服务员上菜,但这时候前台没有服务员接待,顾客可能会得到不太满意的体验
  • 当厨师做完一道菜,准备递交给服务员时,服务员因为处理顾客问题迟迟不来,那么厨师只能干等着而不能去做下一道菜

如果有了一个上菜台,厨师就可以将做好的菜放到上菜台上,这样一来,即使服务员有些事情没办法及时上菜,厨师也可以继续做菜而不用等待。而这个上菜台就是一个容器,用来降低生产者和消费者之间的耦合度,确保双方都在持续地运行。阻塞队列就是充当这样容器的一个工具。

生产者消费者模型的优势:

  • 解耦合:比如两个服务器之间交互,服务器a请求、服务器b就返回响应:


    目前的情况:a服务器的代码逻辑当中含有部分与b服务器有关的代码,同样b服务器当中也有a服务器相关的代码逻辑。

但如果我们加上一个阻塞队列,充当两个服务器之间的过渡桥梁,会怎样呢?

a服务器与队列交互,b服务器与队列交互,a和b服务器不再直接交互,起到了解耦合的作用。

当a或者b服务器中的某些代码逻辑需要更改,就必须两个服务器同时修改,解耦合的好处就是能够方便后续代码的维护。

  • 削峰填谷

试想一下,如果a服务器的请求量在短时间内快速增加,这时候b服务器可能会因为处理请求的速度过慢或者硬件资源条件等而导致崩溃,这时候用户就会得不到页面反馈,也就是我们常说的“卡死页面”(点名学校的抢课系统)

这时候加上阻塞队列,如果a服务器再次出现请求量短时间内激增的情况,阻塞队列里面就会存放大量的请求,但是并不会直接影响到b服务器,而是在b服务器可接受的速度范围内一个一个将请求发送给b服务器,这样一来,就不会出现崩溃的情况了。

但是这样的生产者消费者模型也不是没有代价的:

  • 引入阻塞队列之后整体的结构会更复杂,需要部署更多设备,管理起来就会更麻烦
  • 由于队列的阻塞可能导致效率降低

2.3 java标准库中的阻塞队列

java的标准库(java.util.concurrent)中内置了阻塞队列,我们可以直接使用。

blockingqueue是一个接口,其实现类如下:

其中常用的是基于数组实现的arrayblockingqueue和基于链表实现的linkedblockingqueue。

常用的方法是put和take。(虽然也有offer、poll和peek等方法,但是不具有阻塞特性),这里用linkedblockingqueue来做演示。

blockingqueue<string> queue = new linkedblockingqueue<>(100);  // 可以传入数字作为参数,表示队列的容量
// 入队列
queue.put("hello");
// 出队列
string ret = queue.take();  // 如果没有put元素就take,就会阻塞
system.out.println(ret);

注意:如果实例化队列的时候没有指定容量,默认值是一个很大的数(integer.max_value)。

接下来,我们使用标准库中的blockingqueue实现一个生产者消费者模型。

public class test {
    public static void main(string[] args) {
        blockingqueue<integer> queue = new linkedblockingqueue<>(1000);

        // 生产者
        thread producer = new thread(() -> {
            int n = 0;
            while (true) {
                try {
                    queue.put(n);
                    system.out.println("生产元素" + n);
                    n++;
                } catch (interruptedexception e) {
                    throw new runtimeexception(e);
                }
            }
        }, "producer");

        // 消费者
        thread consumer = new thread(() -> {
            while (true) {
                try {
                    integer n = queue.take();
                    system.out.println("消费元素" + n);
                } catch (interruptedexception e) {
                    throw new runtimeexception(e);
                }
            }
        }, "consumer");

        producer.start();
        consumer.start();
    }
}

2.4 实现阻塞队列

我们自己动手实现一个阻塞队列,在这里采用循环队列的形式,当队列满了就回到队头,能够循环利用空间。

既然使用循环队列,那么就意味着是基于数组实现,同时要定义两个变量指向队头和队尾(head和tail),还有一个size记录当前元素个数。

public class myblockingqueue {
    private string[] data = null;
    private int head;
    private int tail;
    private int size;
}

在构造方法中,将传入的参数capacity作为数组的长度实例化数组对象

public myblockingqueue(int capacity) {
	data = new string[capacity];
}

接着是put方法,参数是要入队列的元素,然后先判断队列是否已满,若未满就入队列。

public void put(string elem) {
    // 判断队列是否已满
    if (size >= data.length) {
        return;
    }
    // 入队列
    data[tail++] = elem;
    // 判断当前tail是否越界
    if (tail >= data.length) {
        tail = 0;  // 这里为了方便就不用取模了
    }
    size++;
}

take方法一是一样的思路,先判断队列是否为空,再出队列。

public string take() {
    // 判断队列是否为空
    if (size == 0) {
        return null;
    }
    // 获取队头元素
    string ret = data[head++];
    // 判断当前head是否越界
    if (head >= data.length) {
        head = 0;
    }
    size--;
    return ret;
}

接下来考虑实现一下阻塞功能,目前还是没有阻塞功能的呢。

那怎样实现阻塞功能?我们可以通过加锁配合wait方法来实现阻塞功能:

加锁,那么当前要put的线程就不会被打扰可以安心地put,如果队列已满,那就需要阻塞,我们就用wait方法来让线程释放锁并进入阻塞状态。当其他线程获取锁并take了元素,这时队列就有空位,那么就可以让刚才put的线程继续put了,这个操作可以通过notify来实现。

因此,put和take方法中,当要进入阻塞状态时,就调用wait方法;当其他线程put/take了元素时,就调用notify唤醒刚才要take/put的线程让它继续执行。

添加了阻塞功能的put和take方法:

public void put(string elem) throws interruptedexception {
    synchronized (this) {
        // 判断队列是否已满
        if (size >= data.length) {
        	// 阻塞
            this.wait();
            //return;
        }
        // 入队列
        data[tail++] = elem;
        // 判断当前tail是否越界
        if (tail >= data.length) {
            tail = 0;
        }
        size++;
        this.notify();
    }
}
public string take() throws interruptedexception {
    synchronized (this) {
        // 判断队列是否为空
        if (size == 0) {
        	// 阻塞
            this.wait();
            //return null;
        }
        // 获取队头元素
        string ret = data[head++];
        // 判断当前head是否越界
        if (head >= data.length) {
            head = 0;
        }
        size--;
        this.notify();
        return ret;
    }
}

但是这样还是存在风险,我们原本期望的是当队列为空/已满的时候阻塞,不继续执行后面的代码了,但是,如果程序中采用try-catch语句,并且因为某些原因而导致wait被中断,那么这样一来就会继续往下执行,从而出现bug:

public string take() {
    synchronized (this) {
        // 判断队列是否为空
        if (size == 0) {
        	// 阻塞
            try {
            	this.wait();
            } catch (interruptedexception e) {
            	e.printstacktrace();  // 可能执行到这里打印完后继续往下执行,出现bug
            }
            //return null;
        }
        // 获取队头元素
        string ret = data[head++];
        // 判断当前head是否越界
        if (head >= data.length) {
            head = 0;
        }
        size--;
        this.notify();
        return ret;
    }
}

为了防止wait被各种原因而提前唤醒,我们可以在判断队列是否为空/已满这里进行二次判断,只需要将if语句改成while语句即可:

public void put(string elem) throws interruptedexception {
    synchronized (this) {
        // 判断队列是否已满
        while (size >= data.length) {
        	// 阻塞
            this.wait();
            //return;
        }
        // 入队列
        data[tail++] = elem;
        // 判断当前tail是否越界
        if (tail >= data.length) {
            tail = 0;
        }
        size++;
        this.notify();
    }
}
public string take() throws interruptedexception {
    synchronized (this) {
        // 判断队列是否为空
        while (size == 0) {
        	// 阻塞
            this.wait();
            //return null;
        }
        // 获取队头元素
        string ret = data[head++];
        // 判断当前head是否越界
        if (head >= data.length) {
            head = 0;
        }
        size--;
        this.notify();
        return ret;
    }
}

到此为止,我们的阻塞队列就完成了:

public class myblockingqueue {
    private string[] data = null;
    private int head;
    private int tail;
    private int size;
    // 构造方法
    public myblockingqueue(int capacity) {
        data = new string[capacity];
    }
    public void put(string elem) throws interruptedexception {
        synchronized (this) {
            // 判断队列是否已满
            while (size >= data.length) {
                this.wait();
                //return;
            }
            // 入队列
            data[tail++] = elem;
            // 判断当前tail是否越界
            if (tail >= data.length) {
                tail = 0;
            }
            size++;
            this.notify();
        }
    }
    public string take() throws interruptedexception {
        synchronized (this) {
            // 判断队列是否为空
            while (size == 0) {
                this.wait();
                //return null;
            }
            // 获取队头元素
            string ret = data[head++];
            // 判断当前head是否越界
            if (head >= data.length) {
                head = 0;
            }
            size--;
            this.notify();
            return ret;
        }
    }
}

案例三、线程池

3.1 线程池是什么

虽然线程的创建开销比进程要少很多,但是每一次启动程序都有大量线程创建的话,消耗的资源也是比较大的,为了节约资源,因此引入线程池的概念。

我们之前学习字符串的时候提到过字符串常量池,其实就是存放字符串对象的一个容器,目的就是能够做到随取随用。

同理,线程池也是如此,我们提前在线程池中创建好若干个线程让它们随时待命,一旦有任务需要执行,就将任务交给它们,它们自己就会在内部讨论谁来执行这个任务。这样一来,就会大大节省频繁创建线程所消耗的资源了。

那为什么直接创建线程比从线程池中取出线程开销更大呢?由于线程是操作系统的概念,我们就回到操作系统吧。

一个操作系统包含一个内核和若干个应用程序,而这个内核承担了操作系统的大部分核心功能比如管理硬件设备和为软件提供稳定的运行环境等。

直接创建线程就是向系统内核申请创建线程,在这个过程中我们就没办法直接干预了,而线程池中的线程是由应用程序创建的,我们可以决定程序是否运行,是可控的,并且还可以省下从应用程序切换到系统内核中运行的开销。

3.2 java标准库中的线程池

threadpoolexecutor

java标准库(java.util.concurrent)中,线程池的核心接口是 executorservice(继承自 executor,是最基础的接口),其实现类(如 threadpoolexecutor)支持自动线程回收等功能。

使用 executors 工具类创建的线程池(如 newcachedthreadpool()、newfixedthreadpool(int))内部也基于 threadpoolexecutor,并默认启用线程回收。

这里的核心方法是submit(runnable),其中runnable描述一段要执行的任务,通过submit()方法将任务添加到线程池中,线程池中的线程就会执行这个任务。

threadpoolexecutor类的构造方法是比较重要的知识点:

我们这里需要记住第四个(也就是最长的那个)构造方法中的所有参数以及它们的用处:

  • corepoolsize:表示核心线程的个数。核心就代表重要,需要跟随线程池整个生命周期,即线程池一创建,这些核心线程也得跟着创建,当线程池销毁了,这些核心线程也随之销毁。
  • maximumpoolsize:最大线程个数,相当于核心线程数 + 非核心线程数(这些线程是自适应的,若繁忙时核心线程处理不过来就会创建,等到不繁忙的时候就会销毁)。
  • keepalivetime:非核心线程允许空闲的最大时间。当没什么任务时,非核心线程不会和核心线程一样一直等待任务,而是有时间限制的,到了一定的时间就会被销毁掉。
  • unit:是一个枚举类型的实例,定义了时间单位的常量,通常与keepalivetime配合使用,用于表示具体时间单位。
  • workqueue:用于存放任务的工作队列,本质上是一个阻塞队列,可以指定底层数据结构(数组/链表)、容量(capacity)以及优先级或者比较规则。线程池也是一种生产者消费者模型,submit()是生产者,池里的线程是消费者。
  • threadfactory:工厂模式,也是一种设计模式,与单例模式并列。工厂模式通过工厂类来创建对象,可以满足不同情况下创建对象的需求,可以看作是对构造方法的一种升级(如果有两种不同的情况,且参数都有两个,无法重载构造方法,也就无法实现多种情况下构造对象的需求了)。如在二维平面上构造一个点对象,可以传入x轴、y轴的值以平面直角坐标系的方式构造对象,也可以传入r(极径:点到极点的距离)、α(极角:极径与极轴的夹角)的值以极坐标的方式构造对象,但是这两种方式的参数都有两个,构造方法的方法名也不能修改,因此无法构成重载,也就无法满足需求。如果采用工厂模式,在工厂类中写两个构造对象的方法(工厂方法),一个通过传入直角坐标系的参数构造对象,另一个通过传入极坐标参数构造对象,若有更多情况也可以增加工厂方法,这样就可以满足不同需求了。
  • handler:拒绝策略,是最重要的。如果任务添加太多,导致工作队列满了,虽然工作队列是一个阻塞队列,但是并不会阻塞。当队列处于已满的状态,并不会真的执行入队列操作——即不会真的阻塞(为什么呢?因为一旦阻塞住了,这个线程就没办法干别的事情了,这会使效率大大下降),取而代之的,是执行拒绝策略的逻辑。拒绝策略总共有四个:1.直接放弃(抛出异常),线程池可能无法继续工作;2.让 submit 当前任务的线程自己执行这个任务;3.丢弃当前队列中最老的任务然后执行当前任务;4.丢弃当前的任务。

executors

由于threadpoolexecutor太过复杂,因此为了简化线程池的使用,java标准库中提供了另一组类executors,针对threadpoolexecutor进一步封装,该类也是基于工厂模式实现的。

我们有两种方法创建线程池:

  • executors.newfixedthreadpool(int)
  • executors.newcachedthreadpool()

其中,第一个方法中含有参数,是可以定义固定线程个数的,并且线程个数始终是固定的,空闲时不会被回收。当没有任务时不会结束,因此需要显式调用shutdown()以终止线程池。

任务队列底层是linkedblockingqueue,是一个无界队列(容量极大,约21亿),这意味着,当提交了新的任务,如果线程全都在忙,就得入队列等待,再进来新的任务也还是会入队等待。队列永远不会被填满,只会越来越长,线程池不会触发拒绝策略。缺点是持续堆积任务可能导致内存溢出。

而第二个方法是能够自动扩容线程个数的,当空闲时会回收一些(空闲时长超过了60秒的)线程以释放空间,当任务过多时就会新建一些线程或者重用一些(空闲时长还未超过60秒的)线程。

工作队列是一种容量为0的阻塞队列,其中每个插入操作必须等待另一个线程执行相应的移除操作,反之亦然。并且此队列不允许包含null元素。当有新的任务到来,先查看有没有空闲线程,有的话直接重用线程,否则新建线程执行任务,如果线程数达到了integer.max_value,就会触发拒绝策略。

3.3 实现线程池

我们动手实现一个固定线程个数的线程池。

线程池最重要的就是submit(),通过该方法才可以向队列中添加任务。

我们可以在构造方法中将执行任务的线程创建出来,由于随时可能有任务被添加进队列,我们需要循环读取队列看看有没有任务,取到任务了就执行,没取到任务就阻塞。

首先定义一个工作队列,然后在构造方法中初始化其容量为1000。接着循环创建n个线程,每一个线程都循环读取任务,若读取到就执行任务,没读取到就阻塞。

private blockingqueue<runnable> queue = null;
// 构造方法
public mythreadpool(int nthreads) {
    // 工作队列的容量设置为1000
    queue = new arrayblockingqueue<>(1000);

    // 创建n个线程
    for (int i = 0; i < nthreads; i++) {
        thread t = new thread(() -> {
            try {
                // 循环读取任务
                while (true) {
                    runnable task = queue.take();
                    // 取到任务就执行
                    task.run();
                }
            } catch (interruptedexception e) {
                throw new runtimeexception(e);
            }
        });
        t.start();
    }
}

接下来是submit方法,只需要将任务放入队列即可

public void submit(runnable task) throws interruptedexception {
    queue.put(task);
}

这样,固定线程个数的线程池就实现了:

public class mythreadpool {
    // 工作队列
    private blockingqueue<runnable> queue = null;
    public mythreadpool(int nthreads) {
        // 工作队列的容量设置为1000
        queue = new arrayblockingqueue<>(1000);

        // 创建n个线程
        for (int i = 0; i < nthreads; i++) {
            thread t = new thread(() -> {
                try {
                    // 循环读取任务
                    while (true) {
                        runnable task = queue.take();
                        // 取到任务就执行
                        task.run();
                    }
                } catch (interruptedexception e) {
                    throw new runtimeexception(e);
                }
            });
            t.start();
        }
    }

    public void submit(runnable task) throws interruptedexception {
        queue.put(task);
    }
}

案例四、定时器

4.1 定时器是什么

定时器是开发中一个重要的组件,就像一个闹钟到达了设定的时间就会响起一样,定时器到达了设定的时间就会执行特定的任务。

4.2 java标准库中的定时器

在java标准库中(java.util)提供了一个 timer 类,其核心方法是 schedule()。

schedule 方法的第一个参数是 task,表示要执行的任务,这里封装成了 timertask,本质还是 runnable。

为什么不直接用 runnable 呢?为什么要封装成 timertask 呢?因为单纯的 runnable 只能够执行任务,而不能记录任务是否被取消、任务是否被执行过、任务是否具有周期性、下次的执行时间、调度状态等这些信息,因此需要封装成 timertask 来解决。

第二个参数就是延迟执行的时间,单位是毫秒。

简单地使用一下 timer ,写一个程序,期望在程序运行后三秒打印出 “hello timer!”

代码如下:

import java.util.timer;
import java.util.timertask;

public class test {
    public static void main(string[] args) {
        timer timer = new timer();

        timer.schedule(new timertask() {
            @override
            public void run() {
                system.out.println("hello timer!");
            }
        }, 3000);
        timer.schedule(new timertask() {
            @override
            public void run() {
                system.out.println("hello timer2!");
            }
        }, 1000);
    }
}

可以发现,当执行完这个任务之后,程序并没有结束,而是在等待新的任务。这意味着,定时器中的线程也包含前台线程。

4.3 实现定时器

我们的实现思路大概是:

  • 用一个类表示任务
  • 在定时器中要有一个能够管理任务的东西,而且它必须能够区分任务的执行时间并据此进行任务的排序
  • 实现 schedule 方法
  • 创建一个线程,让它负责执行任务

首先用一个类表示任务,这个类需要记录下任务的代码逻辑以及对应的执行时刻,状态什么的先不实现了:

// 要执行的任务
private runnable task;
// 对应的执行时刻
private long time;

在构造任务对象的时候就设置这两个属性,所以在构造方法中传入两个参数,设置属性:

// 构造方法
public mytimertask(runnable task, long time) {
    this.task = task;
    this.time = time;
}

那么任务类暂时是这样:

class mytimertask {
    // 要执行的任务
    private runnable task;
    // 对应的执行时刻
    private long time;

    // 构造方法
    public mytimertask(runnable task, long time) {
        this.task = task;
        this.time = time;
    }

    public void run() {
        task.run();
    }
}

接着我们来思考定时器中的用来管理任务的东西,用什么集合类比较合适呢?arraylist?哈希表?还是队列?其实一开始是会想到用队列的,但是因为需要对任务进行排序,因此选择使用优先级队列。

对于我们自定义的类,如果想要使用优先级队列来进行排序,那就得设置一个比较规则,所以我们去任务类中明确一下比较规则(依据任务的执行时刻比较,哪个任务时刻小就排前面)。

首先实现一下 comparable 接口,然后重写 compareto 方法,在 compareto 方法中明确比较规则是:时刻越小越早执行。由于 time 属性是浮点数类型,且 compareto 方法的返回值是 int 类型,因此将返回值强转成 int 类型,这是明确了比较规则的任务类:

class mytimertask implements comparable<mytimertask> {
    // 要执行的任务
    private runnable task;
    // 对应的执行时刻
    private long time;

    // 构造方法
    public mytimertask(runnable task, long time) {
        this.task = task;
        this.time = time;
    }

    public void run() {
        task.run();
    }

    // 定义比较规则,重写 compareto方法
    @override
    public int compareto(mytimertask o) {  // 时刻越小,越早执行
        return (int)(this.time - o.time);  // 返回值是 int,强转
    }
}

这样一来,就可以通过优先级队列管理任务了。

接下来,我们来实现 schedule 方法,这个方法具有两个参数,一个是 runnable 任务,一个是延迟时间 delay。但是我们的队列可容纳的元素是我们自定义的任务类,因此不能直接将参数添加到队列当中,需要先构造一个任务对象,再将任务添加到队列中。

public void schedule(runnable task, long delay) {
    // 构造任务对象,这里任务的执行时刻是 “当前程序执行的时刻 + 延迟时间”
    mytimertask mytimertask = new mytimertask(task, system.currenttimemillis() + delay); 
    // 将任务添加到队列中
    queue.add(mytimertask);
}

然后我们在定时器类中的构造方法创建一个线程,让它负责去执行任务,在构造方法中,我们创建一个线程,让它持续不断地从队列中读取任务(当队列不为空才读取),读取到任务之后,要判断一下当前任务是否到达了执行的时机,在这里,我们判断当前程序执行的时间和任务的执行时刻的大小,若小于,说明还未到执行任务的时机,否则执行任务。

大致是这样:

// 构造方法(不完整)
public mytimer() {
    // 创建一个线程,负责执行任务
    thread t = new thread(() -> {
        // 重复取出任务
        while (true) {
            if (!queue.isempty()) {
                continue;
            }
            mytimertask task = queue.peek();
            // 判断任务是否到达执行时机
            if (system.currenttimemillis() < task.time) {
                continue;
            } else {
                task.run();
            }
        }
    });
}

写到这里发现,由于任务类的 time 属性是私有的,在类外无法访问,因此我们在任务类中添加一个 gettime 方法,获取到当前任务的执行时刻 time 属性。

class mytimertask implements comparable<mytimertask> {
    // 要执行的任务
    private runnable task;
    // 对应的执行时刻
    private long time;

    // 构造方法
    public mytimertask(runnable task, long time) {
        this.task = task;
        this.time = time;
    }

    public void run() {
        task.run();
    }

    // 定义比较规则,重写 compareto方法
    @override
    public int compareto(mytimertask o) {  // 时刻越小,越早执行
        return (int)(this.time - o.time);  // 返回值是 int,强转
    }

    public long gettime() {
        return time;
    }
}

这样,一个简单的、未考虑线程安全的定时器就写好了。但是,仔细思考一下,在主线程中调用 schedule 方法添加任务有一个线程,在定时器内部又有一个执行任务的线程,多个线程并发执行,必然会涉及到线程安全问题。

我们分别在 schedule 方法和定时器类的构造方法中将关键操作加上锁:

public void schedule(runnable task, long delay) {
	// 保障原子性防止出现线程安全问题
    synchronized (this) {
        // 构造任务对象
        mytimertask mytimertask = new mytimertask(task, system.currenttimemillis() + delay);
        // 将任务添加到队列中
        queue.add(mytimertask);
    }
}

// 构造方法
public mytimer() {
    // 创建一个线程,负责执行任务
    thread t = new thread(() -> {
        // 重复取出任务
        while (true) {
            // 保障原子性防止出现线程安全问题
            synchronized (this) {
                if (!queue.isempty()) {
                    continue;
                }
                mytimertask task = queue.peek();
                // 判断任务是否到达执行时机
                if (system.currenttimemillis() < task.gettime()) {
                    continue;
                } else {
                    task.run();
                    queue.poll();
                }
            }
        }
    });
}

可是,当程序运行起来,等一会就会发现风扇狂转,这是因为我们添加的任务很少,当线程读不到任务就会一直读取队列,而队列中一直没有任务添加进来,就会执行 continue,这样一直循环,这就是 “ 忙等 ” ,等了半天什么都没有执行到,还需要消耗大量的资源,因此需要优化。

这里不好用 sleep 来处理,因为没办法控制好时间,万一设置地太短就起不到作用了,而太长又会耽误新来的任务。

因此这里选择使用 wait / notify 来处理。如果队列为空,就 wait,等到 schedule 添加了新任务之后再 notify ,这时候 wait 的线程就可以继续执行逻辑了。在这里因为习惯进行二次确认,将取出任务这里的 if 语句改成 while 语句,能够再次确认一下。

在 schedule 方法中也要添加 notify,以便唤醒 wait 的线程。

修改后的 schedule 方法和构造方法:

// shcedule方法:将任务提交到队列中
public void schedule(runnable task, long delay) {
    // 为保证线程安全,加锁
    synchronized (this) {
        mytimertask timertask = new mytimertask(task, system.currenttimemillis() + delay);
        queue.offer(timertask);
        // 唤醒线程
        this.notify();
    }
}

// 构造方法中创建一个线程,负责取出任务并按规定的时间执行
public mytimer() {
    thread t = new thread(() -> {
        try {
            // 为保障线程安全,加锁
            synchronized (this) {
                while (true) {
                    // 取出任务
                    while (queue.isempty()) {
                        // continue;
                        this.wait();
                    }
                    mytimertask task = queue.peek();
                    // 判断当前任务是否到达执行时机
                    if (system.currenttimemillis() < task.gettime()) {
                        // 系统时间比任务执行时间小,说明还未达到执行时机
                        continue;
                    } else {
                        // 执行任务
                        task.run();
                        queue.poll();
                    }
                }
            }
        } catch (interruptedexception e) {
            throw new runtimeexception(e);
        }
    });
    t.start();
}

但是,在任务执行时机判断这里也需要用 wait 来处理,并且这里的 wait 是有时间限制的,当到达了执行时刻,wait 就得被唤醒了,否则就无法按时执行任务了。我们可以传入参数,将任务执行时刻与当前程序执行时间的差值作为参数传给 wait,当差值为 0,也就是执行时机到了,wait 就会被唤醒,然后执行任务。

这样就可以达到要求了。

这里有个小细节:当 wait 的时候即使恰好有一个新的任务添加进来,就会阻塞在加锁这里,防止 wait 被提前唤醒。

如果任务很多,只靠一个线程来执行任务可能执行不过来,到时候可以线程池来执行~

到这里,简单定时器就已经实现完成了。

代码如下:

// 用一个类表示任务
class mytimertask implements comparable<mytimertask>{
    // 任务
    private runnable task;
    // 记录任务执行的时刻
    private long time;

    // 构造方法
    public mytimertask(runnable task, long time) {
        this.task = task;
        this.time = time;
    }

    public void run() {
        task.run();
    }

    // 定义比较规则,重写 compareto方法
    // 返回值小的作为堆顶元素,优先执行
    @override
    public int compareto(mytimertask o) {
        return (int)(this.time - o.time);
    }

    public long gettime() {
        return time;
    }
}

// 定时器类
class mytimer {
    // 任务队列
    private priorityqueue<mytimertask> queue = new priorityqueue<>();

    // shcedule方法:将任务提交到队列中
    public void schedule(runnable task, long delay) {
        // 为保证线程安全,加锁
        synchronized (this) {
            mytimertask timertask = new mytimertask(task, system.currenttimemillis() + delay);
            queue.offer(timertask);
            // 唤醒线程
            this.notify();
        }
    }

    // 构造方法中创建一个线程,负责取出任务并按规定的时间执行
    public mytimer() {
        thread t = new thread(() -> {
            try {
                // 为保障线程安全,加锁
                synchronized (this) {
                    while (true) {
                        // 取出任务
                        while (queue.isempty()) {
                            // continue;
                            this.wait();
                        }
                        mytimertask task = queue.peek();
                        // 判断当前任务是否到达执行时机
                        if (system.currenttimemillis() < task.gettime()) {
                            // 系统时间比任务执行时间小,说明还未达到执行时机
                            //continue;
                            this.wait(task.gettime() - system.currenttimemillis());
                        } else {
                            // 执行任务
                            task.run();
                            queue.poll();
                        }
                    }
                }
            } catch (interruptedexception e) {
                throw new runtimeexception(e);
            }
        });
        t.start();
    }
}

那今天到这里就告一段落了,若有错误请尽管指出!😊🌹

总结

到此这篇关于java多线程四个案例精讲的文章就介绍到这了,更多相关java多线程案例内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!

赞 (0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com