• JavaEE:多线程(3):案例代码


    目录

    案例一:单例模式

    饿汉模式

    懒汉模式

    思考:懒汉模式是否线程安全?

    案例二:阻塞队列

    可以实现生产者消费者模型

    削峰填谷

    接下来我们自己实现一个阻塞队列

    1.先实现一个循环队列

    2. 引入锁,实现线程安全

    3.实现阻塞

    实现生产者消费者模型

    案例三:定时器

    问题

    线程安全

    线程饿死

    理解代码过程

    案例四:线程池

    标准库中的线程池:ThreadPoolExecutor

    Executors工厂类

    手敲线程池


    多线程基础知识要点

    案例一:单例模式

    是一种设计模式

    软件设计需要框架,这是硬性的规定;设计模式是软性的规定。遵循好设计模式,代码的下限就被兜住了

    单例 = 单个实例(对象)

    某个类在一个进程中只应该创建出一个实例(原则上不应该有多个)

    使用单例模式可以对代码进行一个更严格的校验和检查

    实现单例模式~

    饿汉模式

    第1步:

    1. class Singleton{
    2. private static Singleton instance = new Singleton();
    3. }

    这里的static指的是类属性,而instance就是Singleton类对象持有的属性

    每个类的类对象只存在一个,类对象中的static属性自然只有一个了

    因此instance指向的这个对象,就是唯一的对象

    第2步:

    其他代码要想使用这个类的实例就需要通过这个方法来进行获取。不应该在其他代码钟重新new这个对象,而是使用这个方法获取到现成的对象(已经创建好的对象)

    第3步:奇淫巧计

    这里直接把Singleton给private了,其他代码根本没办法new

    此时,无论你创建多少个对象,这些对象其实都是一样的

    饿汉模式下,实例是在类加载的时候就创建了,创建时机非常早,相当于程序一启动,实例就创建了。“饿汉”形容“创建实例非常迫切,非常早”

    欸!但是用非常规手段:反射就可以打破上述约定。我们可以用枚举方法来创建单例模式


    懒汉模式

    创建实例的时机更晚,只到第一次使用的时候才会创建实例

    “懒”的思想

    比如有一个非常大的文件(10GB),有一个编辑器,使用编辑器打开这个文件,如果是按照饿汉的方式,编辑器就要把这10GB先加载到内存里,然后再统一地展示。加载太多数据,用户还得一点点看,没办法一下看那么多

    如果按照懒汉的方式,编辑器就会制度取一小部分数据,把这部分数据先展示出来,随着用户翻页之类的操作再继续读后面的数据。这样效率可以提高

    1. class SingletonLazy{
    2. private static SingletonLazy instance = null;//先初始化为null,不是立即初始化
    3. public static SingletonLazy getInstance(){
    4. if (instance == null){
    5. instance = new SingletonLazy();//首次调用getInstance才会创建出一个实例
    6. }
    7. return instance;
    8. }
    9. private SingletonLazy(){}
    10. }

    思考:懒汉模式是否线程安全?

    这样t1 new了一个对象,t2也new了一个对象,就会出现bug

    所以,懒汉模式不是线程安全的

    那怎么改成线程安全的呢?

    1.加锁,synchronized

    2.把if和new两个操作打包成一个原子

    仍然是t1和t2两个线程,t1先执行加锁代码,t2就被阻塞了,要等待t1释放锁才能继续执行

    而t1把instance修改之后,t2的if条件就不成立了,直接就返回了


    emm,这段代码还不够完美...

    在多线程里面,当第一个线程加了锁,后面的线程再调用getInstance就是纯粹的读操作了,也就不会有线程问题了。那么没有线程的代码每次执行都要加锁和解锁,每次都会产生阻塞,效率巨低!

    所以在synchronized外边还得再套一层if,判定代码是否要加锁。仍然将instance是否为空作为判断条件

    第一个if判定是否加锁

    第二个if判定是否要创建对象 


    🆗上面的代码还有一点问题

    涉及到指令重排序引起的线程安全问题

    指令重排序是指调整原有代码的执行顺序,保证逻辑不变的前提下提高程序的效率

    为什么调整代码执行顺序可以提高程序效率?

    比如我们去超市买东西,我们需要买黄瓜,胡萝卜,西红柿,土豆。我们就有很多种去不同摊位的路径选择,每种选择的最终总购买时间不一样。这就相当于程序的效率。

    这行代码可以分成三个大步骤

    1.申请一段内存空间

    2.在这个内存上调用构造方法,创建出这个实例

    3.把这个内存地址赋给Instance引用变量

    假设有t1和t2两个线程

    t1线程按照1 3 2的执行顺序,就会出现问题

    解决上述问题核心思路:volatile

    volatile有两个功能:

    1)保证内存可见性,每次访问变量必须要重新读取内存,而不会优化到寄存器/缓存中

    2)禁止指令重排序,针对这个volatile修饰的变量的读写操作的相关指令,是不能被重排序的

    这样修改之后,针对instance变量的读写操作就不会出现重排序


    案例二:阻塞队列

    特点:1.线程安全;2.阻塞

    如果一个已经满了的队列进行入队列,此时入队列操作就会阻塞,一直阻塞到队列不满之后

    如果一个已经空的队列进行出队列,出队列操作就会阻塞,一直阻塞到队列有元素为止

    可以实现生产者消费者模型

    这个模型可以更好地解耦合(把代码的耦合程度从高降低)

    实际开发中,往往会用到分布式系统,服务器整个功能不是由一个服务器完成的,而是每个服务器负责一部分功能。通过服务器之间的网络通信,最终完成整个功能

    在这个案例中,A和B,C之间的耦合性比较强,一旦B或者C挂了一个,A也就跟着挂了

    如果引入生产者消费者模型

    这个阻塞队列不是简单的数据结构,而是基于这个数据结构实现的服务器程序,又被部署到单独的主机上


    削峰填谷

    为啥当请求多了的时候,服务器就容易挂?

    因为服务器处理每个请求都是要消耗硬件资源(包括但不限于CPU,内存,硬盘,网络带宽),上述任何一种硬件资源达到瓶颈,服务器都会挂

    因为B和C抗压能力比较弱,所以我们可以用一个阻塞队列来承担峰值请求

    阻塞队列:数据结构

    消息队列:基于阻塞队列实现服务器程序

    Java标准库里线程的阻塞队列

    BlickingQueue: ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue

    1. BlockingDeque queue = new ArrayBlockingQueue<>(100);
    2. queue.put("aaa");

    put和offer都是入队列,但是put带有阻塞功能,而offer没带阻塞功能,队列满了会返回布尔结果

    1. String elem = queue.take();
    2. System.out.println("elem = "+elem);

    take用来出队列,带有阻塞功能 


    接下来我们自己实现一个阻塞队列

    1.先实现一个循环队列

    1. class MyBlockingQueue{
    2. private String[] elems = null;
    3. private int head = 0;
    4. private int tail = 0;
    5. private int size = 0;
    6. public MyBlockingQueue(int capacity){
    7. elems = new String[capacity];
    8. }
    9. public void put(String elem){
    10. //新的元素放到tail指向的位置上
    11. if (size >= elems.length){
    12. //队列满了,需要下面这个代码阻塞
    13. return;
    14. }
    15. //新的元素要放到tail指向的元素上
    16. elems[tail] = elem;
    17. tail++;
    18. if(tail >= elems.length){
    19. tail = 0;
    20. }
    21. size++;
    22. }
    23. public String take(){
    24. if(size == 0){
    25. //队列空了,需要下面这个代码阻塞
    26. return null;
    27. }
    28. String elem = elems[head];
    29. head ++;
    30. if(head >= elems.length){
    31. head = 0;
    32. }
    33. size--;
    34. return null;
    35. }
    36. }

    2. 引入锁,实现线程安全

    1. private static Object locker = new Object();
    2. public MyBlockingQueue(int capacity){
    3. elems = new String[capacity];
    4. }
    5. public void put(String elem){
    6. synchronized (locker){
    7. //新的元素放到tail指向的位置上
    8. if (size >= elems.length){
    9. //队列满了,需要下面这个代码阻塞
    10. return;
    11. }
    12. //新的元素要放到tail指向的元素上
    13. elems[tail] = elem;
    14. tail++;
    15. if(tail >= elems.length){
    16. tail = 0;
    17. }
    18. size++;
    19. }
    20. }
    21. public String take(){
    22. String elem = null;
    23. synchronized (locker){
    24. if(size == 0){
    25. //队列空了,需要下面这个代码阻塞
    26. return null;
    27. }
    28. elem = elems[head];
    29. head ++;
    30. if(head >= elems.length){
    31. head = 0;
    32. }
    33. size--;
    34. return elem;
    35. }
    36. }
    37. }

    3.实现阻塞

    对于满了的情况,用wait方法阻塞,在出队列成功之后再进行唤醒

    队列空的情况,在入队列成功后的线程中唤醒

    1. public void put(String elem) throws InterruptedException {
    2. synchronized (locker){
    3. //新的元素放到tail指向的位置上
    4. while (size >= elems.length){
    5. //队列满了,需要下面这个代码阻塞
    6. locker.wait();
    7. }
    8. //新的元素要放到tail指向的元素上
    9. elems[tail] = elem;
    10. tail++;
    11. if(tail >= elems.length){
    12. tail = 0;
    13. }
    14. size++;
    15. //入队列成功后唤醒
    16. locker.notify();
    17. }
    18. }
    19. public String take() throws InterruptedException {
    20. String elem = null;
    21. synchronized (locker){
    22. while (size == 0){
    23. //队列空了,需要下面这个代码阻塞
    24. locker.wait();
    25. }
    26. elem = elems[head];
    27. head ++;
    28. if(head >= elems.length){
    29. head = 0;
    30. }
    31. size--;
    32. //出队列成功后唤醒
    33. locker.notify();
    34. }
    35. return elem;
    36. }

    这里的if为什么改成while了呢?

    因为if只能判定一次条件,有时候一旦程序进入阻塞之后再被唤醒,中间隔的时间会很长,这个间隔过程变数很多,可能这个入队列的条件无法再满足了。

    欸那改成while之后,就是wait唤醒之后再判定一次条件,wait之前判定一次,唤醒之后再判定一次(就是多做一次确定)。再次确认发现队列还是满的,那就继续等待。


    实现生产者消费者模型

    1. public static void main(String[] args) {
    2. MyBlockingQueue queue = new MyBlockingQueue(1000);
    3. //生产者
    4. Thread t1 = new Thread(()->{
    5. int n = 1;
    6. while(true){
    7. try {
    8. queue.put(n + "");
    9. System.out.println("生产元素 " + n);
    10. n++;
    11. Thread.sleep(500);
    12. } catch (InterruptedException e) {
    13. throw new RuntimeException(e);
    14. }
    15. }
    16. });
    17. //消费者
    18. Thread t2 = new Thread(()->{
    19. while(true){
    20. try {
    21. String n = queue.take();
    22. System.out.println("消费元素 " + n);
    23. } catch (InterruptedException e) {
    24. throw new RuntimeException(e);
    25. }
    26. }
    27. });
    28. t1.start();
    29. t2.start();
    30. }

    实际开发中,生产者和消费者往往不仅仅是一个线程,也可能是一个独立的服务器程序


    案例三:定时器

    可以设定一个时间,时间到了的时候,定时器自动执行某个逻辑(比如,写博客定时发布)

    用法:定义一个timer添加多个任务,每个任务同时会带有一个时间

    Timer里面内置了前台线程,因为timer不知道你的代码是否还会添加新的任务进来,仍然严正以待

    需要使用cancel来主动结束


    现在我们来手搓一个定时器

    需要有什么?1.一个可以帮我们掐时间的线程;2.一个能帮我们存储任务的优先级队列

    因为每个任务都带有delay时间的,用优先级队列可以先执行时间小的,后执行时间大的

    扫描线程就不必遍历了,只需要关注队首元素是否到时间(队首没到时间,其他元素也没到时间)

    计时任务

    任务优先级逻辑(时间小的优先级越高)

    计时器


    问题

    线程安全

    由于我们在主线程中对队列的元素进行添加,而扫描线程对已完成的元素进行删除,两个线程操作同一个优先级队列变量,会有线程安全问题

    此时需要加锁来解决线程安全问题

    schedule方法(主线程要调用)的加锁


     判断:以下哪种加锁方法是正确的

    1. //第一种
    2. public MyTimer(){
    3. t = new Thread(()->{
    4. //扫描线程就需要循环的反复扫描队首元素,然后判定队首任务时间是否到达
    5. //时间到了就执行任务并删除这个任务
    6. //时间没到就啥都不干
    7. synchronized (locker){
    8. while (true){
    9. if (queue.isEmpty()){
    10. continue;
    11. }
    12. MyTimerTask task = queue.peek();
    13. //获取当前时间
    14. long curTime = System.currentTimeMillis();
    15. if(curTime >= task.getTime()){
    16. //当前时间已经到了任务时间,就可以执行任务了
    17. queue.poll();
    18. task.run();
    19. }else{
    20. //时间还没到,暂时先不执行
    21. continue;
    22. }
    23. }
    24. }
    25. });
    26. //第二种
    27. public MyTimer(){
    28. t = new Thread(()->{
    29. //扫描线程就需要循环的反复扫描队首元素,然后判定队首任务时间是否到达
    30. //时间到了就执行任务并删除这个任务
    31. //时间没到就啥都不干
    32. while (true){
    33. synchronized (locker){
    34. if (queue.isEmpty()){
    35. continue;
    36. }
    37. MyTimerTask task = queue.peek();
    38. //获取当前时间
    39. long curTime = System.currentTimeMillis();
    40. if(curTime >= task.getTime()){
    41. //当前时间已经到了任务时间,就可以执行任务了
    42. queue.poll();
    43. task.run();
    44. }else{
    45. //时间还没到,暂时先不执行
    46. continue;
    47. }
    48. }
    49. }
    50. });
    51. }

    第一种方法,把锁放到while外面,如果while没有结束的话,锁永远都释放不了,主线程调用schedule方法就永远上不了锁。所以我们要采用第二种方法,把锁加到while里面,才有释放锁的机会


    线程饿死

    上面的第二种方法虽然解决了线程安全问题,但是这部分代码执行速度很快,解锁之后就立即重新加锁,导致其他线程想通过schedule加锁都加不上,所以我们需要使用wait来解决

    一旦由新的任务加入,wait就会被唤醒,因为不知道加入的任务是不是最早的任务,所以我们用task.getTime() - curTime来获取任务时间

    没有新的任务,时间到了。按照原定计划,执行之前的这个最早的任务即可

    执行结果


    理解代码过程

    理解peek:

    优先级队列:无论添加多少元素,这里的peek都是得到时间最小的值。

    理解run方法

    👇

    👇

    👇(Runnable作为描述任务的主体)

    👇

    main方法里面写出任务具体执行代码


    案例四:线程池

    池是什么?

    池就相当于一个共享资源,是对资源的整合和调配,节省存储空间,当需要的时候可以直接在池中取,用完之后再还回去。比如,如果你喝水,你可以拿杯子去水龙头接。如果很多人喝水,那就只能排队去接。

    Java常用的池有常量池,数据库连接池,线程池,进程池,内存池

    最开始进程能够解决并发编程的问题,但是因为频繁创建销毁进程的成本太高了,引入了线程这种轻量级进程。但是如果创建销毁线程的频率进一步提高,这里的开销也不能忽视

    那怎么优化线程创建销毁效率呢?

    1.引入轻量级线程--纤程/协程

    协程本质是程序员在用户态代码中进行调度,不是靠内核的调度器来调度的

    协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。可以节省很多调度上的开销。

    ⚠线程里有协程这句话是不严谨的,因为协程本身不是系统级别的概念,是用户代码中基于线程封装出来的,有不同的实现方法,可能n个协程对应1个线程,也可能n个协程对应m个线程

    2.引入线程池。把要使用的线程提前创建好,用完了也不要直接释放而是备下次使用,就节省创建/销毁线程的开销

    从线程池里取线程(纯用户态代码)比从系统申请更高效!

    比如一个事情一个人自己就能完成,就更可控,更高效,这种相当于纯用户态代码

    但是如果这个事情这个人要拜托其他人来完成,不知道委托人要花多少时间,就不可控,更低效。相当于去系统申请线程


    标准库中的线程池:ThreadPoolExecutor

    构造方法(面试题常考)

    标准库提供的线程池,持有的线程个数并不是一成不变的,会根据当前的任务量自适应线程个数 

     核心线程数(规定一个线程池里最少有多少个线程)

    最大线程数(规定一个线程最大有多少个线程)

    某个线程超过保持存活时间阈值就会被销毁掉

    和定时器类似,线程池中可以持有多个任务

     -- 线程工厂

    通过这个工厂类创建线程对象(Thread对象),在这个类里面提供了方法,让方法封装new Thread的操作,同时给Thread设置一些属性

    设计模式:工厂模式。通过专门的工厂类/对象来创建指定的对象

    例子:

    1. //平面上的一个点
    2. class Point{
    3.         public Point(double x, double y){...}//通过笛卡尔坐标构造这个点
    4.         //还可以用三角函数转换笛卡尔坐标
    5.         //x = r * cos(a); y = r * sin(a)
    6.         public Point(double r, double a){...}//通过极坐标系来构造点(半径,角度)
    7. }

    上面代码能编译通过吗?不能。因为不能构成重载(因为形参类型和个数相同了)

    为了让上面代码通过,就可以引入工厂模式

    1. class Point{
    2. //工厂方法
    3. public static Point makePointByXY(double x, double y){
    4. Point p = new Point();
    5. p.setX(x);
    6. p.setY(y);
    7. return p;
    8. }
    9. public static Point makePointByRA(double r, double a){
    10. Point p = new Point();
    11. p.setR(r);
    12. p.setA(a);
    13. return p;
    14. }
    15. Point p = Point.makePointByXY(x, y);
    16. Point p = Point.makePointByRA(r, a);
    17. }

    通过静态方法封装new操作,在方法内部设定不同的属性完成对象初始化,这个构造对象的过程就是工厂模式

    拒绝策略

    在线程池中,有一个阻塞队列,能够容纳的元素有上限,当任务队列已经满了,如果继续往队列中添加任务,线程池中就会拒绝添加

    四种拒绝策略

    第一种:继续添加任务,直接抛出异常

    第二种:新的任务由添加任务的线程负责执行(线程池不会执行)--谁揽的活谁干

    第三种:丢弃最老的任务

    第四种:丢弃最新的任务


    Executors工厂类

    通过这个类创建不同的线程池对象

    例子

    1. public static void main(String[] args) {
    2. ExecutorService service = Executors.newFixedThreadPool(4);
    3. service.submit(new Runnable() {
    4. @Override
    5. public void run() {
    6. }
    7. });
    8. }

    啥时候使用Executors,啥时候使用ThreadPoolExecutor

    Executors方便只是简单用一下,ThreadPoolExecutor希望高度定制化


    线程池里最好有多少个线程?(具体情况具体分析,回答具体数字就是错误的)

    线程里的任务分成两种

    CPU密集型任务:这个线程大部分时间都在CPU上运行/计算。比如在线程run里面计算1+2+3+...+10w。

    IO密集型任务:这个线程大部分时间都在等待IO,不需要去CPU上运行。比如线程run里加scanner,读取用户输入。

    如果一个进程中,所有的线程都是CPU密集型,每个线程所有的工作都在CPU上执行。此时,线程数目就不应该超过N(CPU逻辑核心数)。——每个线程都要占一个核,超过N就失控了

    如果一个进程中,所有的线程都是IO密集型,每个线程大部分工作都在等待IO,CPU消耗非常少。此时线程数目就可以很多,远远超过N。——一个线程工作,其他线程休息,不霸占CPU核


    手敲线程池

    1.提供构造方法,指定创建多少个线程

    2.在构造方法中,把这些线程都创建好

    3.有一个阻塞队列,能够持有要执行的任务

    4.提供submit方法,能够添加新的任务

    写的过程中遇到问题

    run变量捕获到i之后,正常情况i是不能变的,但是i因为循环造成改变,引发编译器异常

    此处的n就是一个实时final变量,每次循环就创建一个不可变的n,这个n是可以被捕获的

    1. package Thread;
    2. import java.util.ArrayList;
    3. import java.util.List;
    4. import java.util.concurrent.ArrayBlockingQueue;
    5. import java.util.concurrent.BlockingQueue;
    6. class MyThreadPoolExecutor{
    7. private List threadList = new ArrayList<>();
    8. //创建一个用来保存任务的队列
    9. private BlockingQueue queue = new ArrayBlockingQueue<>(1000);
    10. //通过n指定创建多少个线程
    11. public MyThreadPoolExecutor(int n){
    12. for (int i = 0; i < n; i++) {
    13. Thread t = new Thread(()->{
    14. while (true) {
    15. try {
    16. //此处take带有阻塞功能,如果此处队列为空,take就会阻塞
    17. Runnable runnable = queue.take();
    18. //取出一个任务就执行一个任务
    19. runnable.run();
    20. } catch (InterruptedException e) {
    21. e.printStackTrace();
    22. }
    23. }
    24. });
    25. t.start();
    26. threadList.add(t);
    27. }
    28. }
    29. public void submit(Runnable runnable) throws InterruptedException{
    30. queue.put(runnable);
    31. }
    32. }
    33. public class ThreadDemo11 {
    34. public static void main(String[] args) throws InterruptedException{
    35. MyThreadPoolExecutor executor = new MyThreadPoolExecutor(4);
    36. for (int i = 0; i < 1000; i++) {
    37. int n = i;
    38. executor.submit(new Runnable() {
    39. @Override
    40. public void run() {
    41. System.out.println("执行任务 "+ n + ", 当前线程为:" + Thread.currentThread().getName());
    42. }
    43. });
    44. }
    45. }
    46. }

    更体现多线程执行顺序不确定

  • 相关阅读:
    基础算法--二分查找
    2022薪酬调查结果,CRISC和CDPSE更是包揽了冠亚军
    JAVA反射机制详解
    C语言实验三 选择结构程序设计
    【C++】日期类的实现
    陈海波:OpenHarmony技术领先,产学研深度协同,生态蓬勃发展
    Win11怎么修改关机界面颜色?Win11修改关机界面颜色的方法
    SpringBoot基础知识
    深入理解WPF中的依赖注入和控制反转
    C# 多线程
  • 原文地址:https://blog.csdn.net/hellg/article/details/135760151