线程池和常见锁策略

线程池

池(pool)是一个非常重要的思想方法

内存池

进程池

链接池

常量池......这些"池"概念上都是一样的

如果我们需要频繁的创建销毁线程,此时创建销毁线程的成本就不能忽视了,因此我们可以使用线程池,线程池就是提前创建好一波线程,后续需要使用线程就从池子里拿就行,当线程不再使用,就放回池子里,把创建销毁线程变成从池子里拿和归还线程

但是为啥从池子里取会比创建线程更快更高效呢?

因为如果是从系统这里创建线程,就需要调用系统的API,进一步又操作系统内核完成线程创建的过程(内核是给所有进程提供服务的,谁知道啥时候轮到你,这是不可控的), 但是如果是从线程池这里获取线程,上述的内核中进行的操作,都提前做好了,现在的取线程的过程,纯粹的用户代码完成的(纯用户态),这是用户自己控制的,是可控的

Java的标准库中,也提供了现成的线程池

 等号左边是线程池对象,等号右边是创建线程池对象的过程,其中Executors称为"工厂类",而newFixedThreadPool是"工厂方法",这个4是线程数量

这里我们就涉及到一个模式,工厂模式,工厂,顾名思义,就是生产对象的意思,我们一般创建对象都是通过new并且通过构造方法,但是构造方法存在重大缺陷,这个缺陷就是:构造方法的名字固定就是类名,但是有的类,需要有多种不同的构造方式,但是构造方法名字又固定,就只能使用方法重载的方式来实现了,重载的参数的个数和类型需要有差别.此时我们就可以使用工厂模式解决上述问题

举个缺陷的例子,这是一个点,描述一个点可以通过坐标或者极坐标

class Point{    //两种构造方法

   publicPoint(double x,double y){};  //通过坐标的构造方法

   publicPoint(double x,double y){};  //通过极坐标的构造方法

}

这两种构造方式,参数的个数和类型是一样的,无法构成重载

我们可以使用工厂模式解决上述问题,不用构造方法了,使用普通的方法来构造对象,这样的方法名字就可以是任意的了,然后普通方法内部我们再来new对象,然后由于普通方法目的是为了创建出对象来,这样的方法一般得是静态的

class Point{

public static makePointXY(double x,double y){};

public static makePointRA(double x,double y){};

}

此时我们想new一个对象就可以:Point p = Point makePointXY(10,20);

上面的代码我们就称之为工厂模式,这两个方法我们称之为工厂方法

下面我们看看几个创建线程池的方法

ExecutorService service = Executors.newFixedThreadPool(4);
Executors.newCachedThreadPool();//创建出一个线程数目动态变化的线程池
Executors.newSingleThreadExecutor();//包含单个线程,比原生的线程API要更简单一点
Executors.newScheduledThreadPool();//类似于定时器的效果,添加一些任务,任务都在后续的某个时刻再执行,被执行的时候,不止一个扫描线程来执行任务,可能有多个线程来执行任务,类似于一个线程分几个任务
        

 线程池对象搞好了之后,使用submit方法,就可以把多个任务添加到线程池中,看代码

public class Demo23 {
    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(4);
        for (int i = 0;i < 1000;i++) {
            service.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello");
                }
            });
        }
    }
}

,线程池中有四个线程,这四个线程就会合力把我们分配的1000个任务一起完成

除了上述这些线程池之外,标准库还提供了一个接口更丰富的线程池类

ThreadPoolExecutor

上面那四个线程池都是针对ThreadPoolExecutor这个原生的类进行的封装,这四个线程池为了使用方便,已经做了一层封装了

这个原生的线程池提供的接口非常丰富,有很多供我们调整的选项,能更好地满足需求

 经典面试题,谈谈Java标准库里的线程池构造方法的参数和含义

ThreadPoolExecutor 里面的线程个数,并非是固定不变的,会根据当前任务的情况动态发生改变

这样既能保证繁忙的时候高效地处理任务,又能保证空闲的时候不会浪费资源

int corePoolSize           核心线程数(意思是线程池中至少要有这么多线程,哪怕没有任务)

int maximumPoolSize  最大线程数(意思是最多不能超过这些线程,哪怕线程池冒烟了)

long KeepAliveTime (时间),TimeUnit unit (时间单位),假如设定是3000ms,那么线程空闲时间超过3000ms就会被销毁

BlockingQueue<Runnable> workQueue 线程池内部有很多任务,这些任务,可以使用阻塞队列来管理

线程池可以内置阻塞队列,也可以手动指定一个

ThreadFactory ThreadFactory 工厂模式,通过这个工厂类来创建线程

RejectedExecutionHandler handler 线程池考察的重点,也叫作拒绝方式/拒绝策略,线程池有一个阻塞队列,当阻塞队列满了以后,继续添加任务,该如何应对

第一个应对方法:ThreadPoolExecutor.AbortPolicy (直接抛出异常,线程池就不干活了)

第二个应对方法:ThreadPoolExecutor.CallerRunsPolicy (谁是添加这个新任务的线程,就谁去执行这个任务,把任务丢回去)

第三个应对方法:ThreadPoolExecutor.DiscardOldestPolicy (丢弃最早的任务,执行新的任务)

第四个应对方法:ThreadPoolExecutor.DiscardPolicy (直接丢弃新的任务)

上面谈到的线程池有两组,一组是封装过的,Executors

一组是原生的,ThreadPoolExecutor

用哪个都可以,主要还是看实际的需求

但是有的公司更建议用原生的,因为更直观,每一个方面怎么设定的在构造方法都有体现

接下来我们要自己实现一个线程池

import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque;

class MyThreadPool{
    private BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();//用阻塞队列来保存若干个任务
    //通过这个方法,把任务添加到线程池中
    public void submit(Runnable runnable) throws InterruptedException {
        queue.put(runnable);
    }
    //n表示线程池里有几个线程
    //创建了一个固定数量的线程池
    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();
        }
    }
}
public class Demo24 {
    public static void main(String[] args) throws InterruptedException {
        MyThreadPool pool = new MyThreadPool(4);
        for (int i = 0;i < 1000;i++){
            pool.submit((new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello");
                }
            }));
        }
    }
}

运行结果就是打印了1000个hello,这1000个hello都是由线程池这四个线程一起打印出来的,并且是随机的

 由于线程的调度是随机的,所以当前给这个线程中插入的任务在执行的时候,分配给各个线程的任务数量也不一定均等,都看缘分

创建线程池的时候,线程个数是咋来的(就是代码中的那个4怎么来的?)

不同的项目中,线程要做的工作,是不一样的

有的线程的工作是"CPU密集型",线程的工作全是运算(大部分工作都是要在CPU上完成的,CPU得给他安排核心去完成工作才可以有进展)如果CPU是N个核心,当线程数量也是N的时候,理想情况就是每个核心上一个线程,如果搞很多线程,那也是在排队等待,不会有新的进展

有的线程的工作是"IO密集型",读写文件,等待用户输入,网络通信(大部分时候都在等,等的过程中没有使用CPU)这样的线程多一些,也不会给CPU造成太大的负担

实际开发中,一个线程往往是一部分工作是CPU密集的,一部分工作是IO密集的,这个线程里有几成是CPU密集的,有几成是IO密集的,这是很难量化的,这里更好的做法是通过实验的方式来找到合适的线程数,通过性能测试,尝试不同的线程数目,尝试过程中,找到性能和系统资源开销,比较均衡的数值

接下来是多进程的进阶

多进程进阶,主要围绕一些多线程相关的面试题展开的

1.常见的锁策略

锁策略就属于是,实现锁的人需要重点去理解的,如果只是单纯使用锁,就不太需要知道

我们需要认识几种常见的锁策略,能够知道概念,并且能阐述清楚

锁策略指的不是一个具体的锁,它是一个抽象的概念,描述的是锁的特性,描述的是"一类锁"

乐观锁VS悲观锁

乐观锁:预测该场景中,不太会出现锁冲突的情况(后续做的工作更少)

悲观锁:预测该场景,非常容易出现锁冲突(后续做的工作会更多)

锁冲突:两个线程尝试获取一把锁,一个线程获取成功,另一个线程阻塞等待

锁冲突的概率大小,会对后续的工作有一定影响,举个例子就是,假如你喜欢校花/校草,那么如果你想追求成功,你遇到情敌的概率就会非常大,那你想要追上他,就需要做更多的工作

重量级锁VS轻量级锁

重量级锁:加锁的开销是比较大的(花的时间多,占用系统资源多)

               一个悲观锁做的工作更多,很可能是重量级锁(不绝对)

轻量级锁:加锁的开销比较小(花的时间少,占用系统资源少)

                一个乐观锁做的工作更少,很可能是轻量级锁(不绝对)

悲观乐观,是在加锁之前,对锁冲突的预测,决定工作的多少

重量轻量,是在加锁之后,考量实际的锁的开销

正是因为它们概念存在重合,针对一个具体的锁,我们看待的角度不同,对其称呼也会不同,有人叫他乐观锁,也会有人叫他轻量级锁

自旋锁(Spin Lock)VS挂起等待锁

自旋锁:是轻量级锁的一种典型实现

  在用户态下,通过自旋的方式(while循环),实现类似于加锁的效果,举个例子就是,追一个人的时候,发现他有对象了,但是还是坚持跟他发早安晚安联络感情,这样就可以等他分手的时候第一时间上位

这种锁,会消耗一定的CPU资源,但也可以做到最快的速度拿到锁

挂起等待锁:是重量级锁的一种典型实现

  通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,会牵扯到内核对于线程的调度,使冲突的线程挂起(阻塞等待),举个例子,就是追一个人的时候,发现他有对象了,就不再联络了,偶然间又听说他单身了,再去联络这个人,这样做不能第一时间发现他分手,有可能等你发现,他已经换了好几个对象了

这种方式,消耗的CPU资源是更少的,也就无法第一时间拿到锁

读写锁VS互斥锁

读写锁:把读操作加锁和写操作加锁分开了

一个事实:多线程同时去读同一个变量,不涉及线程安全问题

如果两个线程,一个线程读加锁,另一个线程也是读加锁,不会产生锁竞争,此时多线程并发执行的效率更高,两个读操作并不会有线程安全问题,所以大家不用争,这样效率就会提高

如果两个线程,一个线程写加锁,另一个线程也是写加锁,会产生锁竞争

如果两个线程,一个线程读\写加锁,另一个线程是读加锁,会产生锁竞争

Java标准库里,也提供了现成的读写锁

公平锁VS非公平锁

公平锁是遵守先来后到的锁,比如说追校花,校花有对象,大家按照追的时间长短排队,等到校花分手.时间最长的人就可以先和校花在一起,这就是公平锁

非公平锁就是不管先来还是后到,大家公平竞争,这个看起来是概率均等,实际上是不公平的,每个线程的阻塞时间是不一样的

操作系统自带的锁(pthread_mutex)属于是非公平锁

要想实现公平锁,就需要一些额外的数据结构来支持,比如需要有办法记录每个线程的阻塞时间

可重入锁VS不可重入锁

如果一个线程,针对一把锁,连续加锁两次,会出现死锁,就是不可重入锁;不会出现死锁,就是可重入锁

什么是死锁?我们放在下一篇博客

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值