JAVA多线程 - JUC

Java多线程(JUC)

  • 线程基本知识
    • 一、线程与进程
    • 二、线程安全与不安全
    • 三、CopyOnWriteArrayList --- 读写分离,写时复制
  • 线程进阶
    • 一、多线程8锁
    • 二、生-消模型
    • 三、控制线程顺序
    • 四、读写分离-ReadWriteLock
    • 五、主线程等待 --- CountDownLath
    • 六 、循环屏障 --- CyclicBarrier
    • 七 、信号灯 --- Semaphore
    • 八 、Callable
    • 九 、阻塞队列 --- BlockingQueue
    • 十 、线程池
    • 十一、手写线程池

线程基本知识

一、线程与进程

1、线程与进程是什么

进程:是执行中一段程序,即一旦程序被载入到内存中并准备执行,它就是一个进程。进程是表示资源分配的的基本概念,又是调度运行的基本单位,是系统中的并发执行的单位。

线程:单个进程中执行中每个任务就是一个线程。线程是进程中执行运算的最小单位。

2、线程能做什么

一个进程中可以有多个线程,也就是说,在一个进程运行时,会有很多属于这个进程的线程为其干活,而计算机中的线程是采用时间片轮转的方式来获取时间片,如线程A得到了时间片执行了0.03秒,线程A暂停,线程B得到了时间片,执行了0.001秒,时间片在分给别的线程。因此给用户造成了一个假象,就仿佛是几个任务在同时运行一样。这样的好处是,如果一个任务极其占用时间,会阻塞其他任务,因此采用这种方案可以防止堵塞,也可以增加CPU的利用率

3、举个例子

用QQ打个比方,我们说QQ主程序是一个进程,这个进程中包含聊天线程,下载线程,视频线程,用户在使用的时候,可以后台下着软件的同时聊着天。

再比如,我们使用IDEA写代码的时候,会自动提示,自动判断语法错误,也就是说,在我们敲代码的同时,后台还有提示以及差错线程在为我们服务。

二、线程安全与不安全

我们都知道ArrayList是线程不安全的,可能有点人说,我平时用ArrayList用的挺好啊, 没发现什么不对啊, 但是当你用多个线程同时操作ArrayList的时候,就会发现结果是不正确的,有时候ArrayList里面存储的是NULL,有的时候会少存储一个,甚至会报ConcurrentModificationException异常。

那么ArrayList不安全谁安全呢,肯定有人说Vector啊,Vector是线程安全的,没错,我们深究底层,发现Vector的构造方法调用this(10)重载构造方法,说明Vector初始大小是10,而体现在线程安全的代码如下图所示。

Vector添加方法JAVA多线程 - JUC_第1张图片
不出所料,就是加了个synchronized。那么我们在对比下ArrayList的添加源码。
ArrayList添加方法
在这里插入图片描述
果然ArrayList没有被Vector添加方法修饰。

Vector是ArrayList的上一代,Vector牺牲了性能保证了安全性,ArrayList牺牲了安全性保证了性能。

其实还有一种方案,就是通过Collections.synchronizedList(new ArrayList) 将ArrayList转换成线程安全的对象。

那上面的方案看起来换汤不换药,那有没有更好的方案去解决高并发呢,首先给大家引入一个重要概念读写分离,写时复制

三、CopyOnWriteArrayList — 读写分离,写时复制

读写分离,写时复制听起来好像不太好懂,我们拿上课签到举例子:

需求:现在有一张空名单,来的人要将自己的名字写上去完成签到。

方案一:采用线程不安全的方案
小三正在纸上写着自己的名字,由于想签到的同学(线程)太多了,没有老师来限制他们,他们就疯的一般上前去签到,小三刚刚写完 “ 小 ” 字,就被被人抢走了,导致数据出现错误。

方案二:采用加锁方案
小三正在纸上写着自己的名字,由于想签到的同学(线程)太多了,每个人都想先签到,但是老师让他们在门外等着,只有小三签到以后他们才能签到。也就是说小三签到的时候,别人看不到已签到名单(写则不能读)。

方案三:读写分离,写时复制。
我们都知道,只有写操作才会对资源造成修改,但是读不会对资源造成修改,所以我们就将已签到名单粘在墙上,同学们都可以看墙上的名单(并发读),当有同学要签到的时候,将墙上的名单复制(别管怎么复制,这只是例子,他有超能力)一份,把自己的名字写在复制版名单上(加锁写),在此期间并没有影响其他同学读名单,然后在将墙上的替换掉。这就是读写分离,写时复制

CopyOnWriteArrayList就是采用这种思想,在实际实现中,我们发现只需要将List list = new ArrayList();替换成List list = new CopyOnWriteArrayList();之后就可以解决高并发产生的问题。

但是我们不知仅仅满足解决这个问题,也不能满足仅仅懂得读写分离,写时复制的思想,我们还应该了解CopyOnWriteArrayList的底层源码。

CopyOnWriteArrayList 构造方法
JAVA多线程 - JUC_第2张图片
根据注释以及代码,我们知道,构造方法实际上就是创建一个空的Object[0]的数组。
CopyOnWriteArrayList的代码很容易理解,如下图所示。
JAVA多线程 - JUC_第3张图片
我们发现CopyOnWriteArrayList实现了List接口,因此可以使用List接口回调。我们还发现了lock,这是JUC里面的知识,详见线程进阶第三章,简单的说就是一把锁,还是有一个是Object修饰的一个数组。

CopyOnWriteArrayList基本的组成部分我们看了,再看看关键的add()方法。
CopyOnWriteArraylist add方法
JAVA多线程 - JUC_第4张图片
当有线程抢到这把锁之后.

  • 首先上锁 lock.lock();
  • 得到当前数组(得到当前签到表) Object[] elements = getArray();
  • 得到当前数组长度(得到签到的人数) int len = elements.length;
  • 将数组长度加1并拷贝到 新的Object数组中(复制名单)。 Object[] newElements = Arrays.copyOf(elements, len+1);
  • 写入数据(写名字)。 newElements[len] = e;
  • 替换原数组(替换名单) setArray(newElements);
  • 释放锁 lock.unlock();

做个总结:

Copyoniwrite容器即写时复制的容器。

往一个容器添加元素的时候,不直接往当前容器object[]添加,而是先将当前容器object[]进行copy复制出一个新的容器object[ ] newELements,然后在新的容器object[ ] newELements里添加元素,添加完元素之后,再将原容器的引用指向新的容器setArray (newELements);。

这样做的好处是可以对CopyoOnwrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以copyonwrite容器也是一种读写分离的思想,读和写不同的容器

扩展
顺带提一下HashSet,HashSet的底层是HashMap,丢进去的值就是Key,Value是固定的一个Object常量,名字叫PERSENT。

我们举一反三,既然ArrayList可以通过Collections.synchronizedList(new ArrayList) 转换成线程安全。

那么Collections.synchronizedList()是否同样适用于HashSet呢?答案是可以的。
那么Collections.synchronizedList()是否同样适用于HashMap呢?答案是可以的。

那按照读写分离的方式有CopyOnWriteArrayList,那有没有CopyOnWriteArraySet呢,答案也是有的。
HashMap比较特殊,他的并发类是 ConcurrentHashMap<>();

线程进阶

一、多线程8锁

资源类(仅供参考)

class Phone     // Phone.java -> Phone.class -> load... JVM里面形成模板Class
{
     
    public static synchronized void sendEmail() throws Exception
    {
     
        TimeUnit.SECONDS.sleep(4);
        System.out.println("-----sendEmail");
    }

    public synchronized void sendSMS() throws Exception
    {
     
        System.out.println("-----sendSMS");
    }

    public void sayHello()
    {
     
        System.out.println("-----sayHello");
    }
}

主类(仅供参考)

public class Lock8
{
     
    public static void main(String[] args) throws Exception
    {
     
        Phone phone = new Phone();//this1
        Phone phone2 = new Phone();//this2

        new Thread(() -> {
     
            try
            {
     
                phone.sendEmail();
            } catch (Exception e) {
     
                e.printStackTrace();
            }
        },"A").start();

        Thread.sleep(100);

        new Thread(() -> {
     
            try
            {
     
                //phone.sendSMS();
                //phone.sayHello();
                phone2.sendSMS();
            } catch (Exception e) {
     
                e.printStackTrace();
            }
        },"B").start();
    }
}

题目:多线程8锁

1、 标准访问,当邮件和短音都有锁的时候,请问先打印邮件还是短信?
答:先打印邮件,后打印短信
结论:一个对象里面如果有多个synchronized方法,某一个时刻内,只要一个线程去调用其中的一个synchronized方法了,

2、 邮件新增暂停4秒钟的方法,请问先打印邮件还是短信?
答: 先打印邮件后打印短信。
结论: 锁的是当前对象this,被锁定后,其它的线程都不能进入到当前对象的其它的synchronized方法

3 、 新增普通的hello方法,请问先打印邮件还是hello
答:先打印邮件在打印hello
结论: 加个普通方法后发现和同步锁无关

4、 有两部手机,请问先打印邮件还是短信?
答:先打短信后打邮件
结论: 换成两个对象后,不是同一把锁了,情况立刻变化,谁先执行完就先打印谁,不冲突。

5、 两个静态同步方法,同一部手机,请问先打印邮件(静态)还是短信?
答:先打邮件后打短信,既谁先抢到谁就锁。
结论:见(6)

6 、两个静态同步方法,2部手机,请问先打印邮件还是短信?
答:先打邮件再打短信
结论: synchronized是实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下3种形式。
(1)对于普通同步方法,锁是当前实例对象等同于同步方法块。
(2) 对于同步方法块。锁的是Synchonized括号里配置的对象。
(3)对于静态同步方法,锁是当前类的Class对象本身,

7 、1个静态同步方法,1个普通同步方法,1部手机,请问先打印邮件(静态)还是短信?
答:先打短信在打邮件
结论: 见(8)

8 、1个静态同步方法,1个普通同步方法,2部手机,请问先打印邮件还是短信?
答:先打短信在打邮件
结论:所有的静态同步方法用的也是同一把锁——类对象本身,就是我们说过的唯一模板Class,具体实例对象this和唯一模板Class,这两把锁是两个不同的对象,所以静态同步方法与普通同步方法之间是不会有竞态条件的,但是一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后。

二、生-消模型

题目

现在两个线程,可以操作初始值为零的一个变量, 实现一个线程对该变量加1,一个线程对该变量减1,实现交替,来10轮,变量初始值为零。

资源类(仅供参考)

class AirConditioner//资源类
{
     
    private int number = 0;
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();

    public synchronized void increment()throws Exception
    {
     
        //1 判断(为了防止虚假唤醒应该用while)
        if (number != 0)
        {
     
            this.wait();//A C
        }
        //2 干活
        number++;
        System.out.println(Thread.currentThread().getName()+"\t"+number);
        //3 通知
        this.notifyAll();
    }

    public synchronized void decrement()throws Exception
    {
     
        //1 判断
        if(number == 0)
        {
     
            this.wait();
        }
        //2 干活
        number--;
        System.out.println(Thread.currentThread().getName()+"\t"+number);
        //3 通知
        this.notifyAll();
    }
}

主类(仅供参考)

public class ThreadWaitNotifyDemo
{
     
    public  static void main(String[] args)throws Exception
    {
     
        AirConditioner airConditioner = new AirConditioner();

        new Thread(() -> {
     
            for (int i = 1; i <=10; i++) {
     
                try {
     
                    Thread.sleep(200);
                    airConditioner.increment();
                } catch (Exception e) {
     
                    e.printStackTrace();
                }
            }
        },"A").start();

        new Thread(() -> {
     
            for (int i = 1; i <=10; i++) {
     
                try {
     
                    Thread.sleep(300);
                    airConditioner.decrement();
                } catch (Exception e) {
     
                    e.printStackTrace();
                }
            }
        },"B").start();

在这个样例中,主类中使用两个线程去操作资源类是没有报错的,当线程超过3个时,结果会出现错误,这种现象就叫做虚假唤醒。

JAVA多线程 - JUC_第5张图片
在这个流程中
在第一个步骤增一个。
第二个步骤,A线程结束,number=1,理想情况是 ‘ - ’ 进来。
但是在第三个步骤 A进程‘ + ’ 再次进来,此时发现number = 1, wait,此时有A这一个线程等待;
第四个步骤 B线程抢到锁, ‘ +’ ’ 进来了,发现number = 1,wait,此时有A、B两个线程等待。
第五个步骤 ‘ - ’ 进来了,消费一个,线程执行完毕,此时number = 0, 唤醒生产者。
生产者被唤醒之后直接将排队的A线程和B线程放行,此时number = 2,这就是虚假唤醒的由来。

解决虚假唤醒的办法是,将 if 改为while, 这样每次唤醒的时候,while会在将排队的线程 “拉”回去重新验证一次。

三、控制线程顺序

题目

多线程之间按顺序调用,实现A->B->C 三个线程启动,要求如下: AA打印5次,BB打印10次,CC打印15次接着 AA打印5次,BB打印10次,CC打印15次…来10轮

注意

  • 1 高聚低合前提下,线程操作资源类
  • 2 判断/干活/通知
  • 3 多线程交互中,必须要防止多线程的虚假唤醒,也即(判断只用while,不能用if)
  • 4 注意判断标志位的更新
  • 5 没有JUC基础请移步心之所往的帖子:
    https://blog.csdn.net/qq_38946877/article/details/103745455

资源类(仅供参考

class ShareResource
{
     
    private int flag = 1;// 1:A 2:B 3:C
    private Lock lock = new ReentrantLock();
    private Condition c1 = lock.newCondition();
    private Condition c2 = lock.newCondition();
    private Condition c3 = lock.newCondition();


    public void print5()
    {
     
        lock.lock();
        try
        {
     
            //1 判断
            while(flag != 1)
            {
     
                c1.await();// A系统就要停
            }
            //2 干活
            for (int i = 1; i <=5; i++) {
     
                System.out.println(Thread.currentThread().getName()+"\t"+i);
            }
            //3 通知
            flag = 2;
            c2.signal();
        }catch (Exception e){
     
            e.printStackTrace();
        }finally {
     
            lock.unlock();
        }
    }
    public void print10()
    {
     
        lock.lock();
        try
        {
     
            //1 判断
            while(flag != 2)
            {
     
                c2.await();// A系统就要停
            }
            //2 干活
            for (int i = 1; i <=10; i++) {
     
                System.out.println(Thread.currentThread().getName()+"\t"+i);
            }
            //3 通知
            flag = 3;
            c3.signal();
        }catch (Exception e){
     
            e.printStackTrace();
        }finally {
     
            lock.unlock();
        }
    }
    public void print15()
    {
     
        lock.lock();
        try
        {
     
            //1 判断
            while(flag != 3)
            {
     
                c3.await();// A系统就要停
            }
            //2 干活
            for (int i = 1; i <=15; i++) {
     
                System.out.println(Thread.currentThread().getName()+"\t"+i);
            }
            //3 通知
            flag = 1;
            c1.signal();
        }catch (Exception e){
     
            e.printStackTrace();
        }finally {
     
            lock.unlock();
        }
    }
}

主类(仅供参考)

public class ThreadOrderAccess
{
     
    public static void main(String[] args)
    {
     
        ShareResource shareResource = new ShareResource();

        new Thread(() -> {
     
            for (int i = 1; i <=10; i++) {
     
                shareResource.print5();
            }
        },"A").start();

        new Thread(() -> {
     
            for (int i = 1; i <=10; i++) {
     
                shareResource.print10();
            }
        },"B").start();

        new Thread(() -> {
     
            for (int i = 1; i <=10; i++) {
     
                shareResource.print15();
            }
        },"C").start();
    }
}

四、读写分离-ReadWriteLock

题目

多个线程同时读一个资源类没有任何问题,所以为了满足并发量,读取共享资源应该可以同时进行。 但是如果有一个线程想去写共享资源类,就不应该再有其它线程可以对该资源进行读或写。

注意

  • 读-读能共存
  • 读-写不能共存
  • 写-资源类(仅供参考
class MyCache
{
     
    private volatile Map<String,String> map = new HashMap<>();
    private Lock lock = new ReentrantLock();
    public void put(String key ,String value)
    {
     
            System.out.println(Thread.currentThread().getName()+"\t 写入开始");
            map.put(key,value);
            System.out.println(Thread.currentThread().getName()+"\t 写入结束");
     }
    public void get(String key)
    {
     
            String result = null;
            System.out.println(Thread.currentThread().getName()+"\t 读取开始");
            result = map.get(key);
            System.out.println(Thread.currentThread().getName()+"\t 读取结束result: "+result);
    }
}

主类(仅供参考)

public class ReadWriteLockDemo
{
     
    public static void main(String[] args)
    {
     
        MyCache myCache = new MyCache();

        for (int i = 1; i <=10; i++) {
     
            int finalI = i;
            new Thread(() -> {
     
                myCache.put(finalI+"",finalI+"");
            },String.valueOf(i)).start();
        }

        for (int i = 1; i <=10; i++) {
     
            int finalI = i;
            new Thread(() -> {
     
                myCache.get(finalI+"");
            },String.valueOf(i)).start();
        }

    }
}

运行结果
JAVA多线程 - JUC_第6张图片

这是一个错误的样例,可以看到,在资源类中,并没有对put和get进行锁限制,如果直接使用多个线程并发来进行put、get操作,会导致数据出现问题。
打个比方说,上课签到,理想状态是,同学们一人签到,在纸上签名,其他人在旁边看着。而没有加锁会出现小明正在写自己的名字,刚写到 “ 小 ” , “ 明 ” 还没来的及写,就被小刚抢走了。这样会出现数据不一致性,这是我们不愿意看到的。

这样的程序运行结果就是,会有人夹三

那我们如果对写与读加个锁呢 ?

class MyCache
{
     
    private volatile Map<String,String> map = new HashMap<>();
    private Lock lock = new ReentrantLock();
    public void put(String key ,String value)
    {
     
        lock.lock();
        try
        {
     
            System.out.println(Thread.currentThread().getName()+"\t 写入开始");
            map.put(key,value);
            System.out.println(Thread.currentThread().getName()+"\t 写入结束");
        }finally {
     
            lock.unlock();
        }

    }
    public void get(String key)
    {
     
        lock.lock();
        try
        {
     
            String result = null;
            System.out.println(Thread.currentThread().getName()+"\t 读取开始");
            result = map.get(key);
            System.out.println(Thread.currentThread().getName()+"\t 读取结束result: "+result);
        }finally {
     
            lock.unlock();
        }
    }
}

运行结果
JAVA多线程 - JUC_第7张图片
还是原来的材料,还是原来的配方,只不过在get和put上各加了一把锁。可以看到,写入已经规范起来了,再也没有夹三的情况出现了,但是随之而来出现了个问题,我们理想中是不希望读被限制的,就像我们允许多个人同时看电影,显而易见,在运行结果中读也被限制了。这会让程序的运行效率变低。

我们都能发现的问题,先人肯定也是可以发现的,并且睿智的先人还为我们提供了解决方案那就是—ReentrantReadWriteLock,这是juc的一个类,它可以做到读写分离。

资源类(仅供参考)

class MyCache
{
     
    private volatile Map<String,String> map = new HashMap<>();

    private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();

    public void put(String key ,String value)
    {
     
        readWriteLock.writeLock().lock();
        try
        {
     
            System.out.println(Thread.currentThread().getName()+"\t 写入开始");
            map.put(key,value);
            System.out.println(Thread.currentThread().getName()+"\t 写入结束");
        }finally {
     
            readWriteLock.writeLock().unlock();
        }

    }
    public void get(String key)
    {
     
        readWriteLock.readLock().lock();
        try
        {
     
            String result = null;
            System.out.println(Thread.currentThread().getName()+"\t 读取开始");
            result = map.get(key);
            System.out.println(Thread.currentThread().getName()+"\t 读取结束result: "+result);
        }finally {
     
            readWriteLock.readLock().unlock();
        }
    }
}

运行结果
JAVA多线程 - JUC_第8张图片

ReentrantReadWriteLock的用法很简单,可以在看到,写是有锁限制的,而读是没有限制的,这样可以最大的利用资源,这个版本才是较好的解决方案。

五、主线程等待 — CountDownLath

题目

我们在上学的时候,往往会遇到晚自习之后班长要等值日生都值日完毕才能关灯锁门。班长不可能先走,然后把值日生都锁在屋子里,而班长就是主线程,6个值日生就是其他线程。

主类(仅供参考)

public class CountDownLatchDemo
{
     
    public static void main(String[] args) throws Exception
    {
     
        CountDownLatch countDownLatch = new CountDownLatch(6);

        for (int i = 1; i <=6; i++) {
     
            new Thread(() -> {
     
                /*try {
                    Thread.sleep(4000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }*/
                System.out.println(Thread.currentThread().getName()+"\t离开教室");
                countDownLatch.countDown();
            },String.valueOf(i)).start();
        }

        countDownLatch.await();
        //countDownLatch.await(2L,TimeUnit.SECONDS);
        System.out.println(Thread.currentThread().getName()+"\t 关门离开");

    }
}

显而易见,如果不适用CountDownLacth的话,由于线程的执行速度快慢不一,所以主线程很可能会在所有线程执行完毕之后在结束,容易想到的方案就是让主线程sleep,但是一秒钟能想到的答案都是错误的,sleep会让程序执行效率变低,而且我们也不知道让它sleep多长时间,所以我们使用CountDownLacth,它可以设置主线程等待的时间。

六 、循环屏障 — CyclicBarrier

题目

我们小的时候,经常会看龙珠这部动漫,众所周知,召唤神龙需要收集齐7颗龙珠,既当前线程需要等待所有相关线程需要全部准备完毕才能执行。

主类(仅供参考)

public class CyclicBarrierDemo
{
     
    public static void main (String[] args)
    {
     
        CyclicBarrier  cyclicBarrier = new CyclicBarrier(7,() -> {
     System.out.println("集齐7颗龙珠,能够召唤神龙");});

        for (int i = 1; i <=7; i++) {
     
            int finalI = i;
            new Thread(() -> {
     
                try {
     
                    System.out.println(Thread.currentThread().getName()+"\t收集到第:"+ finalI +"\t");
                    cyclicBarrier.await();
                } catch (InterruptedException e) {
     
                    e.printStackTrace();
                } catch (BrokenBarrierException e) {
     
                    e.printStackTrace();
                }
            },String.valueOf(i)).start();
        }

    }
}

运行结果
JAVA多线程 - JUC_第9张图片

七 、信号灯 — Semaphore

题目

开车去超市最讨厌的是什么,没错就是抢车位,往往有很多车需要抢多个车位,如果没抢到那么就只能等待了,如果实在等不到只能去更远的 地方找车位或者直接回家了,既多个线程争抢多个资源。

主类(仅供参考)

public class SemaphoreDemo
{
     
    public static void main(String[] args)
    {
     
        Semaphore semaphore = new Semaphore(3); //模拟3个停车位

        for (int i = 1; i <=6; i++) {
     
            new Thread(() -> {
     
                boolean flag = false;
                try
                {
     
                    semaphore.acquire();
                    flag = true;
                    System.out.println(Thread.currentThread().getName()+"\t 抢到车位");
                    //暂停3秒钟线程
                    try {
      TimeUnit.SECONDS.sleep(new Random().nextInt(5)); } catch (InterruptedException e) {
      e.printStackTrace(); }
                    System.out.println(Thread.currentThread().getName()+"\t -------离开车位");
                } catch (InterruptedException e) {
     
                    e.printStackTrace();
                }finally {
     
                    if(flag)
                    {
     
                        semaphore.release();
                    }
                }
            },String.valueOf(i)).start();
        }
    }

}

运行结果
JAVA多线程 - JUC_第10张图片

八 、Callable

大学的时候我们耳熟能详,生产一个线程需要继承Thread类或者实现Runnable接口。而现如今我们应该知道,在JAVA中尽量少的使用继承,因为继承很宝贵,JAVA是一个仅仅支持单继承的语言(C++支持多继承)。所以我们应该尽量的使用继承接口的方式既实现Runnable接口。

随着业务的发展,慢慢的,JAVA1.1时出现的Runnable已经无法满足业务需要了,进而在1.5版本出现了Callable,那么Callable比Runnable好在哪里呢。

在这里我们举一个例子,一个老师给50个学生上课,上课中途,老师渴了,需要喝水。有两种解决方案,一种是老师亲自去买水,来回时间需要5分钟,另一种方案就是老师托人去买水,自己继续讲课。

上面这个例子能很容易的看出来,买水是一件很浪费时间的事情,如果老师亲自去买水,那么50个同学(线程)都需要等待,这就是Runable的解决方案。

Callbale支持泛型,有返回值,显而易见,实现了Callable接口就可以派遣线程作复杂运算,从而不会使当前线程阻塞,这就是异步思想

很显然,1.1时代的Runable和1.5版本的Callable没有一丝一毫的关系。那么怎么使用呢,众所周知,Thread只需要在参数中传入一个匿名内部类或者一个实现过Runnable接口的实现类就可以,建立一个线程。但是Thread没有Callable的构造方法。那么怎么办呢?

可以很容易想到,只需要有一类同时实现Runnable和Callable接口就可以了,因为这样Runnable和Callable就产生了联系,但是并不推荐这样使用。因为Runnable和Callable本质上都是做同一种事情的,就像是工作人员不会带两个工作牌一样。

spring有一个很厉害的思想就是构造注入,引用这个思想,那么能不能有一个类,继承自Runable接口而且将Callable作为一个构造参数从而使得Runable和Callable发生关系。

别说还真有这么一个类,这个类就是FutureTask,我们阅读源码可以发现,FutureTask其实是实现了RunableFuture< V >,而RunableFuture是继承自Runable接口。FutureTask有一个构造方法正需要Callable。

在这里,FutureTask作为一个中间人,它介绍了Ranable和Callable认识,从而使得他们产生关系,这就是适配器模式。下图是FutureTask的关系图。

关系图
JAVA多线程 - JUC_第11张图片

参考类

public class LamdaCallable {
     
    //get 方法一般放在最后一行
    public static void main(String[] args) throws ExecutionException, InterruptedException {
     
        int a = 1;
        System.out.println("第一步:简单计算"+1);
        FutureTask futureTask = new FutureTask(()->{
     
            //callable
            int temp = 10086;
            Thread.sleep(1000);
            System.out.println("第二步:分支线程做了一个很复杂的计算");
            return temp;
        });
        int b = a+2;
        System.out.println("第三步:简单的计算"+b);
        new Thread(futureTask, "a").start();
        int  c = b + (int)futureTask.get();
        System.out.println("第四步:计算完毕"+c);
    }
}

运行结果
在这里插入图片描述

九 、阻塞队列 — BlockingQueue

在多线程领域:所谓阻塞,在某些情况下会挂起线程(既阻塞),一旦条件满足,被挂起的线程又会被自动唤醒。

为什么需要BlockingQueue?

好处是我们不需要关心什么时候阻塞线程,什么时候唤醒线程,因为着一些都被BlockingQueue一手操办了。

在concurrent包发布以前,在多线程环境下,我们每个程序员都必须自己去控制这些细节,尤其还要兼顾效率和安全,这会给我们的程序带来不少复杂度。

阻塞队列是一个队列,数据结构如下图所示:

JAVA多线程 - JUC_第12张图片
阻塞的产生有两种可能性,一种是由于程序的健壮性差导致的阻塞,另一种是由于业务需要所以进行阻塞,如生-消模型。

当队列是空的,从队列中获取元素的操作将会被堵塞。
当队列是满的,为队列添加元素的操作将会被阻塞。

在JAVA中,BlockingQueue实际上是一个接口

JAVA多线程 - JUC_第13张图片
看到关系图后,我们发现BlockingQueue有很多的实现类,同样,BlockingQueue来自于Queue, Queue来自于Collection,而List和Queue是平级,正像是我们熟悉的ArrayLis,LinkedList一样,BlockingQueue也有ArrayBlockingQueue、LinkedBlockingQueue,所以BlockingQueue同样比较容易上手。

BlockingQueue实现类种类分析:
JAVA多线程 - JUC_第14张图片
BlockingQueue实现类方法分析:
JAVA多线程 - JUC_第15张图片
测试参考代码

public class BlockingQueueDemo
{
     
    public static void main(String[] args) throws Exception
    {
     
        BlockingQueue<String> blockingQueue = new ArrayBlockingQueue(3);

        blockingQueue.put("a");
        blockingQueue.put("a");
        blockingQueue.put("a");
        //blockingQueue.put("a");

        System.out.println(blockingQueue.take());
        System.out.println(blockingQueue.take());
        System.out.println(blockingQueue.take());
        System.out.println(blockingQueue.take());


        System.out.println(blockingQueue.offer("a"));
        System.out.println(blockingQueue.offer("a"));
        System.out.println(blockingQueue.offer("a"));
        System.out.println(blockingQueue.offer("x"));

        System.out.println(blockingQueue.poll());
        System.out.println(blockingQueue.poll());
        System.out.println(blockingQueue.poll());
        System.out.println(blockingQueue.poll());

        System.out.println(blockingQueue.add("a"));
        System.out.println(blockingQueue.add("b"));
        System.out.println(blockingQueue.add("c"));

        //System.out.println(blockingQueue.add("x"));

        System.out.println(blockingQueue.element());

        System.out.println(blockingQueue.remove());
        System.out.println(blockingQueue.remove());
        System.out.println(blockingQueue.remove());
        System.out.println(blockingQueue.remove());
    }
}

十 、线程池

众所周知,10年前的单核cup电脑是假的多线程,就像是马戏团小丑玩多个球,cup需要来回切换,现在是多和核电脑 ,多个线程各自跑在独立的cup上,不用切换,效率更高了。

线程池的优势
线程池主要做的工作是控制运行的线程数量,处理过程将线程任务放入队列,可以同时处理这些任务,如果线程数量超过了最大数量,超出的线程阻塞等候,等其他线程执行完毕,再从队列中取出任务来执行。就像是8个停车位,10个车,8个车进入到停车位停车,2个车等待。等其他车走了之后,他们再按照某种约定谁先占有停车位。

线程池的主要特点为线程复用、控制最大并发数、管理线程

  1. 降低资源消耗: 通过重复利用已创建线程降低线程创建和销毁产生的消耗。
  2. 提高响应速度: 当任务到达后,任务可以不等线程创建就能立即执行。
  3. 提高线程的客观理性: 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控

java中的线程池主要是通过Executor框架实现的,该框架中用到了Executor、Executors、ExecutorService、ThreadPoolExecutor这几类。Executor架构图如下图所示:

JAVA多线程 - JUC_第16张图片

线程池是多线程的重点,我对线程池的理解,java多线程主要就是ThreadPollExecutor类,现在混个耳熟,下面会详细解释。

就像是ArrayList一样,我们一般不直接New它,一般都用接口去承接它,如果想使用Java线程池我们最好也用ExecutorService来承接new出来的线程池对象。如以下代码所示:

参考代码

测试参考代码

public class MyThreadPoolDemo
{
     
    public static void main(String[] args)
    {
     
     //  ExecutorService executorService = Executors.newFixedThreadPool(5);//一池5线程
     // ExecutorService executorService = Executors.newSingleThreadExecutor();//一池1线程
        ExecutorService executorService = Executors.newCachedThreadPool();//一池N线程

        try
        {
     
            for (int i = 1; i <=20; i++)//模拟20个客户来银行办理业务,提交请求。customer
            {
     
                executorService.execute(() -> {
     
                    System.out.println(Thread.currentThread().getName()+"\t 办理业务"+new Random().nextInt(10));
                });
            }
        }catch (Exception e){
     
            e.printStackTrace();
        }finally {
     
            executorService.shutdown();
        }
    }
}

newFixedThreadPool 的运行结果展示
JAVA多线程 - JUC_第17张图片

这一段代码代码很简单,在参考代码中可以看到,声明了3个ExecutorService,只不过实现ExecutorService的类不太一样,他们分别是Executors.newFixedThreadPool(5),这代表声明一个线程池,里面放了5个线程,Executors.newSingleThreadExecutor(),这代表一池里面只能放一个线程,这两个例子就像是银行在工作日开了5个窗口,双休日开一个窗口一样,Executors.newCachedThreadPool(),就像他的名字一样,这是一个可以自动改变线程数量的线程池。有必要注意的是,请看清,在这个样例中是用Executors这个工具类的静态方法来实现创建线程池对象的,而非Executor

这三个线程池优缺点各异

  1. newFixedThreadPool
    执行长期任务性能好,创建一个线程池,一池固定又N个线程,有固定线程数的线程池。
  2. newSingleThreadExecutor
    一个任务一个任务的执行,一池一线程。
  3. newCachedThreadPool
    执行很多短期异步任务,线程池根据需要创建新的线程,但在先前构建的线程可用时将重用他们,可扩容,遇强则强

那么上面学了三种线程池的方案,那么用哪一种比较好呢?答案是一个不用


前面的是基础,如果你只会这些,那么不好意思,你只是一个API调用工程师

调用API也要知道这个API的大致实现源码吧!

我们简单粗暴的分析下上面说过的三个方法的源码。

newFixedThreadPool源码
JAVA多线程 - JUC_第18张图片
newSingleThreadExecutor 源码
JAVA多线程 - JUC_第19张图片
newCachedThreadPool源码
在这里插入图片描述
不知道大家是否记得,前面我说过一个叫做ThreadPoolExecutor类,让大家混混脸熟的。

大致一看,哇!发现他们都是通过 ThreadPoolExecutor 类的构造方法创建对象的,还有5个参数。

我们大致分析下这些构造方法

  1. newFixedThreadPool
    创建的线程池corePoolSize和maximumPoolSize值是相等的,它使用的是LinkedBlockingQueue(前面说过的阻塞队列)

  2. newSingleThreadExecutor
    创建的线程池corePoolSize和maximumPoolSize值都是1,它使用的是LinkedBlockingQueue(前面说过的阻塞队列)

  3. newCachedThreadPool
    创建的线程池从corePoolSize设置为0,将maximumPoolSize的值是Integer.MAX_VALUE,它使用的是SynchronousQueue,简单的说就是来了任务就执行,如果线程超过60秒那么就销毁线程。

其实当我们对比分析了这三个构造方法之后,大致上这5个参数的意思都猜出来了。总之,这三个产生线程池的方式都是通过ThreadPoolExecutor的构造方法创建的,那我们看看ThreadPoolExecutor的构造方法是怎么定义的,如图所示:

ThreadPoolExecutor构造方法定义
JAVA多线程 - JUC_第20张图片
我们看了ThreadPoolExecutor的构造方法,发现这 5(重点) 个参数中还有一个this()参数,而这个this是将原有的5个参数打包,又新增了两个参数,现在是 7(重点) 个参数了。

带着好奇的心思,我们在看看this,原来这是一个重载的构造方法,如下图所示

ThreadPoolExecutor重载构造方法定义
JAVA多线程 - JUC_第21张图片
我们发现,实际上,最终用到的有 **7(重点)**参数,而非前面见到的5个。
那么这么多参数都是什么意思呢?

  1. int corePoolSize 线程池中的常驻核心数

  2. int maximumPoolSize 线程池中能够容纳同时执行的最大线程数,必须大于1

  3. long keepAliveTime 多余的空闲线程完成时间
    当线程池数量超过corePoolSize时,当空闲时间达到keepAliveTime时,多余的线程会销毁,直到剩下corePoolSize的数量。

  4. TimeUnit unit keepAliveTime的单位

  5. BlockingQueue wokeQueue 任务队列,被提交但尚未执行的任务。

  6. ThreadFactory threadFactory 表示线程池的工作线程的线程工厂,用于创建线程,一般用默认的就行

  7. RejectedExecutionHandler handler 拒绝策略,表示当前队列满了,并且工作线程大于等于线程池的最大参数(maxmumPoolSize)时如何来拒绝请求执行的runnable的策略

那么有了这些参数,具体的执行流程是怎么样呢? 如下图所示:

线程池底层工作原理
JAVA多线程 - JUC_第22张图片

上图文字解释
JAVA多线程 - JUC_第23张图片
execute()这个方法没见过?别担心,他的用法和前面见到的submit()大致一样。

原理了解了,那么我们用JDK自带的线程池工具重写一个卖票吧。

class Ticket//资源类
{
     
    private int number = 30;
    Lock lock = new ReentrantLock();
    public void sale()
    {
     
        lock.lock();
        try
        {
     
            if(number > 0)
            {
     
                System.out.println(Thread.currentThread().getName()+"\t卖出第:"+(number--)+"\t 还剩下:"+number);
            }
        }catch (Exception e){
     
            e.printStackTrace();
        }finally {
     
            lock.unlock();
        }
    }
}

/**
 * 题目:三个售票员     卖出      30张票
 * 目的:如何写出企业级的多线程程序
 *
 *  ** 高内聚,低耦合
 *
 *  1 高内低耦的前提下,线程       操作          资源类
 *
 */
public class SaleTicket
{
     
    public static void main(String[] args) throws Exception     //main一切程序入口
    {
     
        Ticket ticket = new Ticket();

        ExecutorService executorService = Executors.newFixedThreadPool(3);
        for (int i = 1; i <=30; i++) {
     
            executorService.submit(() -> {
     
                ticket.sale();
            });
        }
        executorService.shutdown();
    }
}

这是一个很简单的案例,指示采用JDK自带的池化技术实现了卖票而已,前面也说了,这三个JDK自带的池化工具最好都不要用,虽然用起来很顺手,为什么不能用请看下章。

十一、手写线程池

写这一章的原因可能有人是认为我想要对线程池的了解更深一点,实际上这只是一部分,在实际开发中,理论上必须要手写线程池。话不多说,我们看一下阿里的开发手册,如下图所示。
JAVA多线程 - JUC_第24张图片
阿里的开发手册说的很清晰,如果用JDK自带的池化工具会导致OOM!可怕!我们用源码解释导致OOM的原因。
newFixedThreadPool 构造方法
在这里插入图片描述
这个不能再熟悉的构造方法中,有一个LinkedBlockingQueue的阻塞队列,用途在前面已经详细说过了,就类似于候客区,那么拿银行举个例子,银行的候客区一般是20个位子,大一点的银行是30-50个位子,这应该是极限了吧,那么如果某家银行的位子是Integer.MAX_VALUE这么多呢,如果真有这么多人来办理业务呢?想必这会把房子撑爆(OOM)吧!下面我们继续看源码:

LinkedBlockingQueue 构造方法
JAVA多线程 - JUC_第25张图片
果然,默认大小是Integer_MAX_VALUE,而与之相反的newSingleThreadExecutor是只有一个位子,这种情况一般也不用,因此要自己手写线程池。想必看到这里大家就没有疑问了。

进入手写线程池的第一步,参考源码,我们可以知道,ThreadPoolExecutor是关键。如图
在这里插入图片描述
参考源码我们可以很容易的写出一个线程池,我们拿银行办理业务做例子。

public class MyThreadPoolDemo
{
     
    public static void main(String[] args)
    {
     

        ExecutorService executorService = new ThreadPoolExecutor(
                2,//coreSize - 常驻核心数
                5,//maximumpoolsize - 最大数
                2L,//keepAliveTime - 销毁非常驻线程等待时间
                TimeUnit.SECONDS,//unit - 时间单位
                new ArrayBlockingQueue<>(3),//BlockingQueue - 阻塞队列,大小为 3(注意)
                Executors.defaultThreadFactory(),//threadFactory - 工厂
                new ThreadPoolExecutor.AbortPolicy());//RejectedExecutionHandler - 拒绝策略
        try
        {
     
            for (int i = 1; i <=10; i++)//模拟n个客户来银行办理业务,提交请求。customer
            {
     
                executorService.execute(() -> {
     
                    System.out.println(Thread.currentThread().getName()+"\t 办理业务"+new Random().nextInt(10));
                });
            }
        }catch (Exception e){
     
            e.printStackTrace();
        }finally {
     
            executorService.shutdown();
        }
    }
}

代码很容易理解,我的问题是,此线程池最多可以同时处理多少任务?如果太多的任务到来,线程池处理不了怎么办?

答案是,在本样例中,最多可以同时执行5+3=8个,公式如下
Γ ( m a x ) = m a x i m u m p o o l s i z e + B l o c k i n g Q u e u e . s i z e ( ) \Gamma(max) = maximumpoolsize+BlockingQueue.size() Γ(max)=maximumpoolsize+BlockingQueue.size()
那么如果太多的任务到来,线程池处理不了就会参考JDK内置的拒绝策略处理,在本样例的AbortPolicy(默认)会直接抛出RejectedExecutionException异常如下图所示,来阻止系统运行,思考可知这样是不合理的。

RejectedExecutionException异常
JAVA多线程 - JUC_第26张图片

JAVA预置了4个常用的拒绝策略

JAVA多线程 - JUC_第27张图片
我们上面已经理解了AbortPolicy,那么CallerRunsPolicy呢,看到官方介绍有些不清晰,看一下执行结果大家懂了。

CallerRunsPolicy 调用者回退
JAVA多线程 - JUC_第28张图片
用过执行20个任务,我们发现,我们是通过main线程去调用线程池去处理任务的,线程池处理不了就把任务退回到调用者(main)线程执行,没有报异常。

DiscardPolicy会丢弃处理不了的任务,就像这样:
DiscardPolicy
JAVA多线程 - JUC_第29张图片
在DiscardPolicy拦截模式下,执行了 10 个任务,默默的丢弃了2个, DiscardOldestPolicy与DiscardPolicy的区别只不过是丢弃等待最久的任务,就不做演示了。

学到这里,对多线程也就基本入门了,还有更多的东西要学。

第一次写博客,如有错误请批评指正,谢谢大家观看。

你可能感兴趣的:(JAVA,java,高并发编程,并发编程,java高并发api,线程池)