多线程实际应用案例

线程安全的单例模式

   单例模式一种设计模式。设计模式就是将在书写代码遇到的一些常见场景时的问题的解决方案。

  单例模式两种经典模式实现

   饿汉模式:会比较着急的完成创建对象。

    基本语法

    

   注:使用static 修饰的成员应该叫做类属性/类成员,不使用其修饰的对象属性/对象成员。在java程序中一个类的类对象只有一份,所以类的类属性也只有一份。并且类对象不是对象,类:是创建实例的模板可以创建出很多对象,对象也叫做实例。类对象其实是.class文件,被java的JVM加载到内存中,表现出来的形式。类对象里有.class文件的一切信息包括其类名,类的成员方法,成员属性等。并且在这个饿汉模式中getInstance方法,只读了变量的内容,没有发生修改或者删除,在多个线程读同一个变量的时,线程依旧安全。

   懒汉模式:不着急的创建实例化对象,想要使用的时候再去创建。

   基本语法


注:懒汉模式的getInstance方法是其想要获取这个实例化对象时,才去创建这个实例化对象。里面包含有读和修改的内容,读和修改不是一个条件里面的(不是原子性的),存在线程安全问题。

在平常使用中我们要确保线程是安全的,所以我们要解决这个线程不安全的问题。而这个不安全的主要原因在于getInstance方法在多线程环境下可能引发的bug。

我们在日常保证线程安全的方法则是进行加锁

   加锁之后的代码

进行加锁时,通过对类对象进行加锁来保证线程安全,因为类对象只有一份,在多个线程调用时就能保证是针对同一个对象进行加锁。

  线程安全的问题得到解决,又产生了新的问题

   刚才线程不安全是因为在instance被初始化创建之前的,没有初始化创建之前,多线程之间调用getInstance就可能同时涉及到读和修改操作,但是instance被初始化创建之后,if条件虽然不执行了,不用担心修该操作,线程安全了。按照这样的方式进行加锁,待instance初始化创建之前和之后,每次调用getInstance都会进行加锁操作即使是初始化创建之后(线程安全了),还是会存在大量锁进行锁竞争。但是这样的锁竞争没有必要,白白耗费资源。

  进行改进:让getInstance在初始化进行之前才能进行加锁,初始化之后就不要在加锁。在加锁处加上一层条件判定instance == null。

 

注:两个if条件一样是巧合,主要目的不同,上层是判断是否要进行加锁,下面是是否创建实例化对象。并且虽然着两个代码是相邻的,但是其执行时间时间可能存在较大差,比如加锁情况下可能会导致其阻塞,加锁可能是10.28执行,判断instance是否是空可能是在10.40执行在这执行期间,instace可能被修改(比如有A和B进程当A进行加锁之前B成功进行了加锁阻塞了A的加锁,并且B还会返回一个已经创建好的instance,A最后就会返回B创建好的instance)。

 现在还有一个问题就是多线程重复调用getInstance时可能大量读instace内存的操作,使编译器进行优化将读内存优化为读寄存器,一旦触发了优化,如果后续的第一个线程创建了instance对象,但是后面的线程没有感知到仍然将其当作null。可能会导致第一个if条件判断失误导致锁竞争,但是不会影响到第二个if判断(synchronized也会保持内存可见性不发生优化),也就是说这样的情况引发了第一个条件的判断,导致锁竞争。解决是将instance加上volatile.

   线程安全:阻塞队列 

   队列是一个符合先进先出的规则的数据结果,阻塞队列也符合这个规则,但是与普通队列相比,阻塞队列里面有一些其他不同于队列的功能。

   线程安全

   阻塞队列会产生阻塞效果如果队列为空,出队列会发生阻塞,阻塞到队列不为空为止。队列不为空,入队列,也会发生阻塞,阻塞到队列不满为止。这个特性可以实现生产者消费者模型。

   生产者和消费者模型是在日常开发中,处理多线程问题的一个典型方案比如日常在家包包子一般情况下多人协同时会比较快。包包子主要涉及到:和面  擀包子皮   包包子    蒸包子    其中擀包子皮和包包子是需要多人协同进行的。  A  B   C 三人来进行擀包子皮,包包子如果三人每次都是依次来进行先擀包子皮,然后在包包子,就存在一定的锁冲突。因为只有一个擀面杖。如果A  只负责擀包子皮,  B和C只负责包包子  A就是包子皮的生产者,要不断的生成包子皮B C就是消费者要不断的消费包子皮 。A 每个擀完包子皮放在一旁的案板上(我这边一般是这)这个案板就类似于这个阻塞队列。也就是阻塞队列可以作为生产者和消费者中的交易场所。 

 案例

 如果有两个服务器AB,A作为入口服务器直接接收用户的网络请求,B作为应用服务器,给提供数据。

 情况1在这两个服务器中如果不使用消费者生产者模型A和B的耦合性是比较强的,在写A代码时需要充分了解B中提供的一些接口。写B代码时也需要了解A是怎样调用B的代码,一旦想将B换成C,需要对A进行大的改动才能运行,如果B出现挂了,A也要挂。使用生产者消费者模型会降低耦合优点1让多个服务器程序之间更加充分的解耦合。实现高内聚低耦合。

 给A和B加上一个阻塞队列,对于请求:A是生产者,B是消费者。对于响应:A是消费者,B是生产者。阻塞队列作为交易场所。A只需要关注和阻塞队列的交互,B也一样,两者不需要认识对方。A挂或者B挂都不会影响对方。

 情况2当没有使用生产者消费者模型,如果请求量突然暴涨(不可控)A暴涨 B暴涨

A作为入口服务器计算量很轻,请求暴涨,影响不大,B作为应用服务器,计算量可能很大,需要的资源也更多。如果请求更多了,需要的资源进一步增强,如果主机的硬件不够,程序就可能挂。使用阻塞队列,当A暴涨请求量时 到阻塞队列中暴涨阻塞队列中没有什么计算量只是存储数据的地方所以能抗压。B这边会因为阻塞队列缓慢释放数据导致B这边会以一个能承受住的最大速率运行不会发生崩溃。这种类似削峰的操作不会持续很长时间,时间中都是某一个时间段,当这个时间段过去,阻塞队列就不进行缓慢释放数据,而是直接传送给B这种类似填谷。

实际开发中使用到的"阻塞队列"并不是一个简单的数据结构了,而是一个一组专门的服务器程序。并且它提供的功能也不仅仅是阻塞队列的功能,还会在这基础之上提供更多的功能(对于数据持久化存储,支持多个数据通道,支持多节点容灾冗余备份,支持管理面板,方便配置参数等)。

   案例

   定时器类似一个闹钟,进行定时,在一段时间之后,会被唤醒并执行某个之前设定好的任务

   使用join(指定超时时间)    sleep(休眠的指定时间) 这两个方法都是基于系统内部的定时器来实现的。

  系统标准库的定时器用法是引入java.util.Time包,通过方法schedule,方法参数有两个一个是任务是什么,一个是多久之后在进行执行。

使用Timer时不需要start也可以进入这个线程,并且需要记住使用时他是你等待多少毫秒之后在执行并且他的这个线程不会销毁一直在等待下一个可能的任务想要销毁只能使用cancle方法。 

  自己实现Timer

  描述任务:创建一个专门的类来表示一个定时器中的任务

   

汇聚任务:使用数据结构将任务放在一起来进行管理

假设如果现在有多个任务,上课、写作业、休息这些都是无序的但是我们需要将这些无序的任务变为有序的,比如先上课,写作业,休息这样的顺序执行,这样就能加快我们的效率根据当前的优先级,在这里我们根据时间小的为优先级来进行排序 。使用堆数据结构(???)

其结构带有优先级的功能和阻塞队列

检查任务

需要一个线程来不断进行检查当前队列的队首元素看其时间是否到达

class MyTimer {
    // 使用一个数据结构, 保存所有要安排的任务.
    private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
    private Object locker = new Object();

    public void schedule(Runnable runnable, long delay) {
        synchronized (locker) {
             queue.offer(new MyTask(runnable, delay));
            locker.notify();
        }
    }

    // 搞个扫描线程.
    public MyTimer() {
        // 创建一个扫描线程
        Thread t = new Thread(() -> {
            // 扫描线程, 需要不停的扫描队首元素, 看是否是到达时间.
            while (true) {
                try {
                    MyTask task = queue.take();
                    long curTime = System.currentTimeMillis();
                    if (curTime < task.getTime()) {
                            queue.put(task);
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }
}

到现在为止出现了许多bug存在两个比较严重的问题

第一个严重的问题是我们没有在MyTask中指定比较规则,在优先级队列时,应该要描述其对象的比较规则,不能直接使用标准库中的类,许多都存在一定的约束规则,不是将自己写的类直接放进去就可以执行它这个功能。

第二个比较严重的问题在于这个while循环不加限制条件循环会执行的很快并且如果队列中的如果没有值还好会陷入阻塞状态,如果队列中不空,并且还没有到时间,就会一直无效的运行下去,这种行为被称作忙等,没有实际的产出只是一直在工作。并且这种操作非常浪费CPU。我们可以基于wait的机制去实现等待时间不用让其一直执行下去,然后时间到了在次进行唤醒即可。等待时间有wait和sleep但是sleep是中间不能被唤醒,wait能唤醒。唤醒的原因是因为如果插入一个比队首元素时间更小的时间,需要唤醒去重新记录最小时间。

改正之后的总代码

import java.util.PriorityQueue;

// 通过这个类, 描述了一个任务
class MyTimerTask implements Comparable<MyTimerTask> {
    // 要有一个要执行的任务
    private Runnable runnable;
    // 还要有一个执行任务的时间
    private long time;

    // 此处的 delay 就是 schedule 方法传入的 "相对时间"
    public MyTimerTask(Runnable runnable, long delay) {
        this.runnable = runnable;
        this.time = System.currentTimeMillis() + delay;
    }

    @Override
    public int compareTo(MyTimerTask o) {
        // 这样的写法, 就是让队首元素是最小时间的值
        return (int) (this.time - o.time);
        // 如果是想让队首元素是最大时间的值
        // return o.time - this.time;
    }

    public long getTime() {
        return time;
    }

    public Runnable getRunnable() {
        return runnable;
    }
}

//定时器
class MyTimer {
    // 使用一个数据结构, 保存所有要安排的任务.

    //创建一个queue的ProityQueue的队列其中可以存放MyTimerTask类型
    private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
    // 使用这个对象作为锁对象.
    private Object locker = new Object();

    public void schedule(Runnable runnable, long delay) {
        synchronized (locker) {
            queue.offer(new MyTimerTask(runnable, delay));
            locker.notify();
        }
    }

    // 搞个扫描线程.
    public MyTimer() {
        // 创建一个扫描线程
        Thread t = new Thread(() -> {
            // 扫描线程, 需要不停的扫描队首元素, 看是否是到达时间.
            while (true) {
                try {
                    synchronized (locker) {
                        // 不要使用 if 作为 wait 的判定条件, 应该使用 while
                        // 使用 while 的目的是为了在 wait 被唤醒的时候, 再次确认一下条件.
                        while (queue.isEmpty()) {
                            // 使用 wait 进行等待.
                            // 这里的 wait, 需要由另外的线程唤醒.
                            // 添加了新的任务, 就应该唤醒.
                            locker.wait();
                        }
                        MyTimerTask task = queue.peek();
                        // 比较一下看当前的队首元素是否可以执行了.
                        long curTime = System.currentTimeMillis();
                        if (curTime >= task.getTime()) {
                            // 当前时间已经达到了任务时间, 就可以执行任务了
                            task.getRunnable().run();
                            // 任务执行完了, 就可以从队列中删除了.
                            queue.poll();
                        } else {
                            // 当前时间还没到任务时间, 暂时不执行任务.
                            // 暂时先啥都不干, 等待下一轮的循环判定了.
                            locker.wait(task.getTime() - curTime);
                        }
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }
}

public class Main{
    public static void main(String[] args) {
        MyTimer timer = new MyTimer();
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("3000");
            }
        }, 3000);
        System.out.println("程序开始执行");
    }
}
 

案例

当进程比较重需要频繁的创建和销毁导致的开销大  可以选择使用进程池或者线程了。

但是线程虽然比进行清了不少但是创建和销毁频率增加到一定程度时,开销仍然不小 ,使用线程池。

线程池:把线程提前创建好,放到池子里,后面如果需要用线程直接从池子中取,就不用在从系统申请了线程用完也不是不还给系统,放回池子中,等待下次使用。为什么说线程放在池子中就比从系统这边申请释放的更快。就要说用户态和内核态了,硬件,驱动,操作系统内核,系统调用,应用程序,这些是计算机系统的基本组成部分,平常我们自己书写的代码一般就在应用程序中来运行这里的代码都被称为用户态运行的代码,有些代码需要调用操作系统的API,其操作中的一部分逻辑操作就会涉及到要去内核中执行,例如调用System.out.println本质上要经过write系统操作,进入到内核中,内核中执行一堆逻辑(如切换控制权由用户态切换为逻辑态等类似的),控制显示器输出字符串。在内核中运行的代码,被称为内核态运行的代码。创建线程,一般本身就需要内核的支持(是在内核中运行PCB,加入到链表中),调用的Thread.start也需要到内核中去运行。将创建好的线程放到池子里,这个池子是用户态实现的,这个放到池子/从池子中取,这全部都是用户态的代码。一般认为纯用户态的操作,效率比经过内核态处理的高。在用户态效率高的原因是因为用户态是可自己控制的,而内核态中是不可控制的有时快,有时慢。线程池中空余的空间也不叫浪费因为有空间才能使线程多存放点,并且发生效果,但如果没有发生效果就浪费了。

线程池的构造方法中的参数代表什么

int corePoolsize  核心线程数

int maximumPoolsize, 最大线程数

long keepAliveTime, 停留时间

TimeUnit unit 时间单位

BlockingQueue<Runnable> workQueue任务队列 线程池会提供—个submit方法让我们把任务注册到线程池中.加到这个任务队列中.

ThreadFactory threadFactory 创建线程

RejectedExecutionHandler handler  拒接策略 当任务队列满了,怎么做直接忽略最新的任务、阻塞等待、直接丢弃最老的任务。

有一个程序,这个程序要并发的/多线程的来完成一些任务如果使用线程池的话,这里的线程数设为多少合适(不仅仅是面试题,也是工作中需要思考的话题)

这是不能回答一个具体的线程数的值,需要通过性能测试的方式来找到合适的值。例如,写一个服务器程序,服务器里通过线程池,多线程的处理用户请求。就可以对这个服务器进行性能测试,比如构造一些请求,发送给服务器.要测试性能,这里的请求就需要构造很多.比如每秒发送500/1000/20根据实际的业务场景,构造一个合适的值。根据这里不同的线程池的线程数,来观察,程序处理任务的速度,程序持有的CPU的占用率当线程数多了,整体的速度是会变快,但是CPU占用率也会高.
当线程数少了,整体的速度是会变慢,但是CPU占用率也会下降。不同类型的程序,因为单个任务,里面CPU上计算的时间和阻塞的时间是分布不相同的.因此凭空出来一个数字往往是不靠谱。

搞了多线程,就是为了让程序跑的更快嘛。为啥要考虑不让CPU占用率太高呢?
对于线上服务器来说,要留有一定的冗余!!随时应对—些可能的突发情况(比如请求突然暴涨)
如果本身已经把CPU快占完了,这时候突然来一波请求的峰值,此时服务器可能直接就挂了~~
 


 

  • 6
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值