JavaEE多线程中的 单例模式与线程池

文章目录

  • 单例模式
    • "饿汉模式"
    • "懒汉模式"
  • 工厂模式
  • 线程池
    • 线程池的使用
    • 线程池的实现
    • 拒绝策略
  • 总结


单例模式

单例模式是一种常见的设计模式(根据一些常见的需求场景,整理出来一些应对的解决方法)

单个实例(某个类,有且只有一个实例) instance (对象)

这个是需求决定的,有些需求场景,就要求实例不能有多个~
通过单例模式,相当于对"单个实例"做了更加严格的约束.

类似于前面学习过的jdbc编程,DataSource(但DataSource不是单例模式)
一个应用程序,有一份数据源就够了~
DataSource就可以作为一个单例~ ~

单例模式,本质上就是借助 编程语言 自身的语法特性,强行限制某个类,不能创建多个实例~

static 修饰的成员/属性 变成了类成员/类属性~
其实当属性变成类属性的时候,就已经是"单个实例",更具体地说,是类对象的属性,而类对象是通过JVM加载.class文件来的
此时类对象,其实在JVM中也是"单例",换句话说,JVM针对某个.class文件只会加载一次,也就只有一个类对象,类对象上面的成员(static修饰),也就只有一份
JavaEE多线程中的 单例模式与线程池_第1张图片

“饿汉模式”

JavaEE多线程中的 单例模式与线程池_第2张图片

把构造方法设为private,此时在类的外面,就无法继续new实例了

这个操作强制禁止了,其他的类中,new Singleton实例

❓ 如果要继续使用这个唯一实例怎么办?

唯一的入口就是通过getInstance方法
在这里插入图片描述
此时也就是强制的保证了,当前的Singleton是"单例"了

上述代码写的实现单例模式的方式,叫做"饿汉模式"

在类加载阶段创建的实例,创建实例的时机非常早,非常急迫~

“懒汉模式”

还有另一种典型的实现单例模式的方式,叫做"懒汉模式"

创建实例的时机更迟,带来了一个更高的效率!

private static SingletonLazy instance = null;

 public static  SingletonLazy getInstance() {
       if (instance == null) {
                instance = new SingletonLazy();
       }
     return instance;
    }

首次调用getInstance的时候才会创建实例,后续再调用getInstance,就立刻返回实例

如果整个代码没人调用getInstance,这样就把构造实例的过程给节省下来了~~
或者即使代码后续有调用getInstance,但是调用的时机比较晚,这个时候,创建实例的时机也就迟了,就和其他耗时的操作岔开了(一般程序刚启动的时候,要初始化的东西很多,系统资源紧张)

❗ ❗ ❗ 但上述代码是线程不安全的!!!

多个线程下同时调用getInstance,会有问题!!!

JavaEE多线程中的 单例模式与线程池_第3张图片
只要把多个操作打包成一个原子操作就可以保证线程安全!!

此处想要达到的效果是线程2读到的是线程1修改之后的值!!
得让线程2的LOAD在线程1执行完new之后再执行!

但是加锁也不是无脑加,因为加锁的代价也可能会比较大~

所以就这个代码而言,实例没有创建之前,是线程不安全的,需要加锁
实例创建之后,是线程安全的,就可以不加锁

因此就可以在加锁的外层,再加上一层判定条件
JavaEE多线程中的 单例模式与线程池_第4张图片

这两个if中间隔着一个加锁操作,加锁就可能会产生竞争,竞争就会导致阻塞,何时唤醒就不一定了,因此第二个if的结果和第一个if的结果可能是截然不同的~
第一个if成立了,第二个if不一定成立~

上述代码还有一个很重要的问题!

假设两个线程同时调用getInstance~
第一个线程拿到锁了,进入第二层if,开始new对象~

new操作本质上也是分成三个步骤~

1. 申请内存,得到内存首地址
2. 调用构造方法,来初始化实例
3. 把内存的首地址赋值给instance引用

这个new操作,可能编译器会进行"指令重排序"的优化~

在单线程的角度下,2和3是可以调换顺序的!!!
(单线程的情况下,此时2和3先执行谁,后执行谁,效果一样)

但此时是多线程!

假设此处触发指令重排序,并且是按照 132 的顺序执行的,有可能在线程1执行了1和3之后,执行2之前,线程2调用了一个getInstance,这相当于得到了一个不完全的对象,只是有内存,但是内存上的数据无效…
此时线程2调用getInstance就会认为Instance非空,就直接返回了Instance,并且在后续就可能会使用Instance里面的属性/方法

使用volatile可以解决这个问题~

private volatile static SingletonLazy instance = null;

加上之后就禁止指令重排序了~

懒汉模式到此就没有问题了!

附上完整的正确的懒汉模式代码:


public class SingletonLazy {
    private volatile static SingletonLazy instance = null;

    public static  SingletonLazy getInstance() {
        if(instance == null) {
            synchronized (SingletonLazy.class) {
                if (instance == null) {
                    instance = new SingletonLazy();
                }
            }
        }


        return instance;
    }

    private SingletonLazy() {
    }

    public static void main(String[] args) {
        SingletonLazy singletonLazy = getInstance();
    }
}

工厂模式

构造方法,存在一定的局限性,为了绕开局限,就引入了工厂模式
构造实例,最主要的就是使用构造方法new
new的过程中,就要会调用构造方法,有的时候希望类提供多种构造实例的方式

就需要重载构造方法,来实现不同版本的对象创建~~
但是重载要求参数个数/类型不同,就带来了一定的限制

JavaEE多线程中的 单例模式与线程池_第5张图片
此处的这个两个版本的构造方法,就无法形成重载

解决这个问题,就可以使用普通方法,代替构造方法…

使用普通方法,在里面分别构造出Point对象,再通过一些其他手段进行设置~~

class Point {
    public static Point makePointByXY(double x,double y) {
        Point p = new Point();
        p.setx(x);
        p.sety(y);
        return p;
    }

    public static Point makePointByRA(double r,double a) {
        Point p = new Point();
        p.setx(x);
        p.sety(y);
        return p;
    }
}

由于构造方法,方法必须和类名相同,因此只能通过重载来完成,不太合适,换成普通方法,直接通过方法名来区分,也就不用使用重载了~

在这里插入图片描述

线程池

Java中,线程池的本体叫做ThreadPoolExecutor,这个东西构造方法写起来非常麻烦,参数特别多,为了简化构造,标准库就提供了一系列的工厂方法,来简化使用

线程池单纯的使用非常简单,使用 submit方法 ,把任务提交到线程池中即可!!
线程池里面就会有一些线程来负责完成这里的任务

线程池可以提高效率~

在建立连接之后,同时也会保留一些之前的连接,后续再需要建立连接,直接从池子里取一个已经连接好的就行了,省下了重新建立连接的过程~

线程就是共享了内存资源,新的线程复用之前的资源,就不必重新申请了~

如果线程创建的速率进一步的频繁了,此时线程创建销毁的开销仍然不能忽略!!
此时就可以使用使用线程池来进一步的优化这里的速度了!!!

就是弄一个池子,创建好很多线程,当需要执行任务的时候,不需要重新创建线程了,而是直接从池子里取一个现成的线程~直接使用,用完了也不释放线程,而是放回到线程池里 ~

❓ 为什么从池子里取比创建线程快呢?
创建线程,是要在操作系统内核中完成的,涉及到用户态->内核态的切换操作
这个操作还是存在一定的开销的!!

应用程序发起的一个创建线程的行为,线程本质上是PCB,是内核中的数据结构!!
应用程序就需要通过系统调用,进入到操作系统的内核中进行,内核完成PCB的创建,把PCB加入调度队列中,然后再返回给应用程序

使用线程池,从线程池取线程,再放回线程池,这是纯的用户态实现的逻辑~~
从系统这里创建线程,则是 用户态-> 内核态 共同完成的逻辑

创建进程,也是通过内核完成的
创建进程和创建线程都要经历用户态->内核态这个过程

用户态,每个进程都是自己执行自己的逻辑~
内核态,一个系统里只有这一份内核在执行逻辑,这个内核要给所有的进程都提供一份服务

最终的结论,使用线程池是纯用户态操作,要比创建线程(经历内核态的操作)要快!!

JavaEE多线程中的 单例模式与线程池_第6张图片

线程池的使用

ExecutorService pool = Executors.newCachedThreadPool();

此处创建线程池,没有显式的new,而是通过另外Executors类的静态方法,newCachedThreadPool 来完成~~
这种方式就叫做工厂模式,这个newCachedThreadPool(),方法就叫做工厂方法~

 ExecutorService pool = Executors.newCachedThreadPool();
        pool.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("任务");
            }
        });

线程池的使用非常简单,使用submit方法,把任务提交到线程池中即可!
线程池里面就会有一些线程来负责完成这里的任务
在这里插入图片描述

线程池的实现

一个线程池可以同时提交N个任务,对应的线程池中有M个线程来负责完成这N个任务

如何把N给任务分配给M个线程呢?

生产者消费者模型,正好可以解决这个问题!!!

先搞一个阻塞队列,每个被提交的任务,都被放到阻塞队列中,搞M个线程来获取队列元素,如果队列空了,M个线程自然阻塞等待,如果队列不为空,每个线程都取任务,执行任务,再来取下一个…
直到队列空,线程继续阻塞~~

class MyThreadPool {
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();

    public void submit(Runnable runnable) throws InterruptedException {
        queue.put(runnable);

    }
    MyThreadPool(int m) {
        //在构造方法中,创建出M个线程,负责完成工作~
        for (int i = 0; i < m; i++) {
            Thread t = new Thread(() -> {
                while(true) {
                    try {
                        Runnable runnable = queue.take();
                        runnable.run();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            t.start();
        }
    }
}
public class TestDemo5 {
    public static void main(String[] args) throws InterruptedException {
        MyThreadPool pool = new MyThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            //变量捕获,只能捕获一个不会改变的变量
            int finalI = i;
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("执行"+ finalI + "任务" +"当前线程 :" + Thread.currentThread().getName());
                }
            });
        }
    }
}

这是一个固定线程数目的线程池:

JavaEE多线程中的 单例模式与线程池_第7张图片其实还可以改成"线程数目自动态变化"的线程池~

标准库里提供的ThreadPoolExecutor其实要更复杂一些,尤其是 构造方法,可以支持很多参数,可以支持很多选项,让我们可以创建出不同风格的线程池~

拿一个ThreadPoolExecutor的构造方法举个例子:

JavaEE多线程中的 单例模式与线程池_第8张图片

corePoolSize : 核心线程数(正式员工)
maximumPoolSize : 最大线程数(正式员工+实习生)

打个比方说明就是:

正式员工不工作也不会被直接开除~(空闲了也不会销毁线程) 实习生如果没有活干了就会被优化(空闲达到一定时间,就会销毁线程)

任务数量也不太确定,有的时候任务多,有的时候任务少~
如果赶上任务多了,正式员工赶不过来,就多搞几个实习生~如果任务少了,实习生就可以开除了!

这个设定是为了防止线程池空闲的时候,摸鱼的线程太多,占用了系统资源

我们应该灵活调配这两个数值,来做到说,既能处理任务峰值,也能在空闲的时候节省系统资源~

keepAliveTime : 是允许实习生摸鱼的最大时间
unit : 时间的单位

workQueue : 手动的给线程池传入一个任务队列,如果不穿,线程池内也有自己内部创建的任务队列

threadFactory : 描述了线程是如何创建的,工厂对象就负责创建线程,程序员就可以手动指定线程的创建策略

RejectedExecutionHandler handler
[重点]: 线程池的拒绝策略!!
线程池的任务队列已经满了(工作线程已经忙不过来了),
如果又有别人往里添加新的任务,应该怎么处理?

这个策略对于实现"高并发"服务器,也是非常有意义的!!
在这里插入图片描述

拒绝策略

JavaEE多线程中的 单例模式与线程池_第9张图片
上面图片是标准库自带的拒绝策略:

1.ThreadPoolExecutor.AbortPolicy

A handler for rejected tasks that throws a RejectedExecutionException.
被拒绝的任务的处理程序抛出RejectedExecutionException异常。
就是线程池不仅这个新的任务不干了,之前的任务也不干了,除非把异常处理掉才可以继续工作

  1. ThreadPoolExecutor.CallerRunsPolicy

A handler for rejected tasks that runs the rejected task directly in the calling thread of the execute method, unless the executor has been shut down, in which case the task is discarded.
被拒绝任务的处理程序直接在execute方法的调用线程中运行被拒绝的任务,除非执行程序已经关闭,在这种情况下任务将被丢弃。(就是调用者来执行这个任务,如果调用者已关闭,那么就丢弃任务)

  1. ThreadPoolExecutor.DiscardOldestPolicy

A handler for rejected tasks that discards the oldest unhandled request and then retries execute, unless the executor is shut down, in which case the task is discarded.
被拒绝任务的处理程序,它丢弃最老的未处理请求,然后重试执行,除非执行程序关闭,在这种情况下任务将被丢弃。(就是先把最老的任务丢弃)

  1. ThreadPoolExecutor.DiscardPolicy

A handler for rejected tasks that silently discards the rejected task.
被拒绝任务的处理程序,它会默默地丢弃被拒绝的任务。(丢弃这个新放进来的任务)

实际开发中,需要根据需求来决定~

延伸问题: 在实际开发中,线程池的线程数目,如何确定?

单问这个问题其实是不可能确定出具体个数的~

  1. 主机的CPU配置是不确定的
  2. 程序的执行特点也是不确定的

你这个程序是CPU密集型(做了大量的算数运算和逻辑判断)的任务还是IO密集型(做了大量的读写网卡/读写硬盘)的任务呢?
并且有些代码里既需要进行很多CPU密集型任务,又需要很多IO任务,很难量化你的程序实际上两种任务的比例~~

如果任务100%是CPU密集型的话,线程数目最多也就是=N,更大就没有意义了,CPU已经被占满了!
如果任务10%是CPU密集型,90%都是在操作IO(不使用CPU),那么线程数目设置成10N也没关系!

但是实际情况下是很难量化这个比例的!!

所以工作中实际的处理方案是进行实验验证!!
针对你的程序进行性能测试,分别给线程出设置成不同的数目~~
比如N,1.5N,2N,0.5N等,都可以试试
分别记录每种情况下,程序的一些核心性能指标和系统负载情况,最终选一个觉得最合适的配置!

面试题:
线程池的优点:

1.降低资源消耗:减少线程的创建和销毁带来的性能开销。
2.提高响应速度:当任务来时可以直接使用,不用等待线程创建
3.可管理性: 进行统一的分配,监控,避免大量的线程间因互相抢占系统资源导致的阻塞现象。

总结

在这里插入图片描述

你可以叫我哒哒呀
本篇到此结束
“莫愁千里路,自有到来风。”
我们顶峰相见!

你可能感兴趣的:(单例模式,java-ee,java)