操作系统--多线程进阶(下)

前言

其实说实话,就算是进阶也感觉自己好多知识没有涉及到。还是一点点来吧,不能心急不是。

一丶Callbale

<1>基本用法

前面我们说了,实现一个多线程有两种方法,第一种是继承一个Thread类

public class 方式一_Thread {

    public static void main(String[] args) {
        //继承Thread:使用Thread重写run方法的方式

        //写法1:自己写一个类继承thread,重写run方法  父类引用指向子类对象
        Thread t = new MyThread(); //父类引用指向子类对象
        //调用start方法才会真正的创建操作系统中的线程,并且申请系统调度执行
        t.start();

        //匿名内部类:本质上还是继承了Thread类
        //new出来的其实是一个没有类名的匿名类(继承了Thread)
        Thread t2 = new Thread(){
            @Override
            public void run() {
                System.out.println("匿名内部类 run");
            }
        };
        t2.start();
    }

    //静态内部类:使用和普通的类没有什么区别
    private static class MyThread extends Thread{
        @Override
        public void run() {//run方法内,描述了线程要执行的任务
            System.out.println("my thread run");
        }
    }
}

第二种呢,就是实现一个Runnable接口

public class 方式二_实现Runnable {

    public static void main(String[] args) {
        //方式二:先创建一个Runnable对象,传入Thread的构造方法
        Thread t = new Thread(new MyRunnable());
        t.start();

        //Runnable也可以使用匿名内部类的写法
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("Runnable匿名内部类 run");
            }
        });
        t2.start();

        //Runnable的lamble
        Thread t3 = new Thread(() -> {
            System.out.println("Runnable匿名内部类 run");
        });
        t3.start();//t3
    }

    //Runnable接口,表示定义线程的任务对象(Thread才是线程本身)
    private static  class MyRunnable implements Runnable{
        @Override
        public void run() {//线程要执行的任务代码
            System.out.println("my runnable run");
        }
    }
}


今天我们涉及到第三种,也就是实现一个Callable接口。Callable接口其实和Runnable接口是很类似的,但是有一个很主要的区别就是Callable会返回一个线程执行的结果。
什么意思呢?
我们来模拟一下使用的场景,就是说我们想要获取一个线程内执行的结果

public class 方式三_Callable {

    static  int i = 0;

    public static void main(String[] args) {

        Runnable r  = new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                i = 1;
            }
        };
        Thread t = new Thread();
        t.start();
        System.out.println(i);
    }
}

使用这种写法可以吗?答案是不行的,因为你没有办法知道t线程和main线程那个先结束,在你打印出i这个变量的时候,t线程它并发执行,所以它什么时候修改i,你是不知道的,这个时候就可以使用我们的Callable接口。具体使用过程如下:

<1>定义一个Callable<泛型>对象,重写带返回值的call方法
<2>创建一个FutureTask未来的任务对象
<3>new Thread(futureTask)
<4>返回值=futureTask.get();当前线程阻塞等待FutureTask任务执行完毕,并获取执行结
果

这样子看可能有点难以理解,那么我们用代码来实现一下


import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class 方式三_Callable2 {
    static int i = 0;

    public static void main(String[] args) throws ExecutionException, InterruptedException {
		//<1>定义一个Callable<泛型>对象,重写带返回值的call方法
        Callable<Integer> r = new Callable<Integer>() {
            @Override
            public Integer call() {
                try {
                    //模拟执行一段任务
                    Thread.sleep(1000);
                    return 1;
                } catch (InterruptedException e) {
                    throw new RuntimeException("出错了");
                }
            }
        };
        //<2>创建一个FutureTask未来的任务对象
        FutureTask<Integer> task = new FutureTask<>(r);
        //<3>new Thread(futureTask)
        Thread t = new Thread(task);
        t.start();
        //想看看t执行的结果: get会让当前线程等待,直到t线程执行完,并获取到Callable的返回值
        //<4>返回值=futureTask.get();当前线程阻塞等待FutureTask任务执行完毕,并获取执行结System.out.println(task.get());
    }
}

这样子我们就能获取对应的执行结果
在这里插入图片描述

<2>拓展应用

这里我们看到了,Callable接口用于获取线程的执行结果。
但是我们就Callable接口还可以用于其他地方嘛?当然是可以的,比如常用的就像我们获取线程池内的结果。
之前我们说了,往线程池内递交任务,是使用我们的pool.execute();方法,这个方法传入的是一个Runnable对象(PS:这里的pool是一个线程池对象)
在这里插入图片描述

但是今天我们拓展一下,用submit方法去递交一个任务线程,二submit方法可以传入三种对象。
在这里插入图片描述
这里的三个重载方法都是会返回一个Future对象。而这里我们就示例,使用一个Callable对象传入获取结果。

Future<Integer> f = pool.submit(new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                return 1;
            }
        });
System.out.println(f.get());

对应执行结果如下:
在这里插入图片描述

二丶共享锁

<1>countDownLatch

这个的使用场景是我们需要等待多个线程执行完毕,然后再去执行某个任务。
是不是感觉很眼熟?没错,之前我们将join方法的时候其实就可以已经达到这个效果了,但是这种写法就很麻烦。
我们还是使用代码来进行说明

public class MyCountLatch {

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            int number = i;
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(number);
                }
            }).start();
        }
        //我们希望在这里等待以上10个线程执行完毕之后,再去做其他事情
        System.out.println("main");
    }
}

现在这个代码的运行结果,“main”肯定是在打印的10个number中间,但是如果说我们想要10个number打印完之后,再去打印"main",那么我们可以怎么做呢?

使 用 T h r e a d . a c t i v e C o u n t ( ) > 1 \color{red}{使用Thread.activeCount() > 1} 使Thread.activeCount()>1

这种用法就是让我们当前的线程来让步

public class MyCountLatch {

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            int number = i;
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(number);
                }
            }).start();
        }
        while (Thread.activeCount() > 1){
            Thread.yield();
        }
        //我们希望在这里等待以上10个线程执行完毕之后,再去做其他事情
        System.out.println("main");
    }
}

在这里插入图片描述

使 用 j o i n \color{red}{使用join} 使join

这种用法就是在循环的时候,把我们的线程保存在一个集合当中,然后去遍历并且调用线程.join

import java.util.ArrayList;

public class CountDownLatchDemo {
    public static void main(String[] args) throws InterruptedException {
        ArrayList arrayList = new ArrayList();
        for (int i = 0; i < 10; i++) {
            final int j = i;
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(j);
                }
            });
            arrayList.add(t);
        }
        for (int i = 0; i < arrayList.size(); i++) {
            Thread t = (Thread) arrayList.get(i);
            t.start();
            t.join();
        }
        //我们希望在这里等待以上10个线程执行完毕之后,再去做其他事情
        System.out.println("main");
    }
}

使 用 c o u n t D o w n L a t c h \color{red}{使用countDownLatch} 使countDownLatch

这种写法就是我们比较推荐的,在正式使用之前,我们需要先仔细了解一下这个类
在这里插入图片描述
可以看到,我们的这个类的构造方法里面保存了一个初始化的int值,用来表示可以同时并发执行的线程的数量。同时这个类里面有几个方法也是我们需要掌握的。
在这里插入图片描述
这个方法简述一下,就是让并发数减1。还有一个比较常用的就是await()方法,让当前线程等待,直到并发数等于0,才能够继续向下执行。

await()方法重载有两种,根据使用的场景不同我们自行去选择即可。
在这里插入图片描述

在这里插入图片描述
然后应用到我们的这个使用场景的话

public class CountDownLatchDemo2 {
    public static void main(String[] args) throws InterruptedException {
        final CountDownLatch latch = new CountDownLatch(10);
        for(int i=0; i<10; i++){
            final int j = i;
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(j);
                    latch.countDown();
                }
            }).start();
        }
        latch.await();
        System.out.println("main");
    }
}

当然,结果肯定也是和我们要求的一样。
在这里插入图片描述

<2>信号量-- Semaphore

信号量是什么呢?它是用来表示“可用资源的个数”,其本质上还是一个计数器。
老规矩,在我们正式使用之前,我们先了解一下这个类。
在这里插入图片描述
有点看不清,那么我们用复制一下API中的原话

一个计数信号量。 在概念上,信号量维持一组许可证。 如果有必要,每个acquire()都会阻塞,直到许可证可用,然后才能使用它。 每个release()添加许可证,潜在地释放阻塞获取方。 但是,没有使用实际的许可证对象; Semaphore只保留可用数量的计数,并相应地执行。

我们的信号量作为一种线程间的通信方式,通常情况下用于限制线程数,而不是访问某些(物理或者逻辑)资源。例如,这是一个使用信号量来控制对一个线程池的访问的类。
然后来看一下它的构造方法
在这里插入图片描述
这里我们统一来说一下

permits:表示初始化的时候设置的可用资源的个数
fair:表示是否是公共的

然后经常使用的方法我们现在说两种。
第一种
在这里插入图片描述

分为有参数和无参数,有参数表示当前并发数 -= 1,无参数表示当前并发数 -= permits
当然,这个减并发数的方法我们要注意了,如果说并发数满足减1的时候,才可以扣除,如果不满足,那么就需要阻塞等待。
在这里插入图片描述

分为有参数和无参数,有参数表示当前并发数 += 1,无参数表示当前并发数 += permits

然后我们的信号量使用场景如下:

( 1 ) 第 一 种 \color{red}{(1)第一种} (1)

等待一组线程执行完毕,再去执行某个任务(这里我们的countDownLatch也可以做到,但是
countDownLatch不能加只能减)

还是用我们上面的使用场景来示例

import java.util.concurrent.Semaphore;

public class SemaphoreDemo {
    public static void main(String[] args) throws InterruptedException {
        final Semaphore sem = new Semaphore(0);
        for(int i=0; i<10; i++){
            final int j = i;
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(j);
                    sem.release();//一个线程执行完,释放一个资源数
                }
            }).start();
        }
        sem.acquire(10);//此时我们要获取10个资源数,不到的话就一直阻塞
        System.out.println("main");
    }
}

对应结果如下:
在这里插入图片描述
( 2 ) 第 二 种 \color{red}{(2)第二种} (2)

同一个时间,最多执行n个线程(有限资源的使用)
什么意思呢?
看如下场景

1.创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源. 
2.acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
3.创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的
执行效果
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        try {
            System.out.println("申请资源");
            semaphore.acquire();
            System.out.println("我获取到资源了");
            Thread.sleep(1000);
            System.out.println("我释放资源了");
            semaphore.release();
       } catch (InterruptedException e) {
            e.printStackTrace();
       }
   }
};
for (int i = 0; i < 20; i++) {
    Thread t = new Thread(runnable);
    t.start();
}

这里我只截取了一部分
在这里插入图片描述
那为什么会出现这种情况呢?
因为并发执行20个线程,每个线程都要先获取资源,可是资源数最多只有四个,如果还要获取只能等待。也就是说,同一时间,最多就只能执行4个线程。

三丶线程安全的集合类

在我们数据结构的学习过程中,我们学了集合类,比如说ArrayList,在比如说LinkedList。但是我们学的这些集合类都是线程不安全的。当然也有线程安全的,比如说Vector,HashTable,Stack

<1>多线程使用List

上面说了,目前常用的List的集合中常用的两个类都是线程不安全的,虽然说Vector,HashTable,Stack这些都是线程安全的,但是我们不推荐使用,因为它的性能太差了。他们保证线程安全的方式都是synchronized加锁,这就意味着同一个对象中,所有的方法都没办法并发并行执行。所以那么我们怎么办呢?

方 法 一 \color{red}{方法一}

<1>使用同步的List:Collections.synchronizedList(new ArrayList);

<2>使用 CopyOnWriteArrayList

这里需要说明一下第二个方法,使用这个方法的时候,会即时复写一个复制的容器

1.当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复
制出一个新的容器,然后新的容器里添加元素。
2.添加完元素之后,再将原容器的引用指向新的容器。

以上是写操作,如果我们要读的时候,就直接对CopyOnWrite容器进行并发的读,就不需要加锁,因为我们当前容器不会再加任何元素了。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

第二种方法一般适用于读多写少的场景。

优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争. 
缺点:
1. 空间换空间的思想,占用内存较多. 
2. 新写的数据,如果写操作没有被执行完,那就不能被第一时间读取到

<2>使用队列

一句话,使用阻塞队列

1. ArrayBlockingQueue
基于数组实现的阻塞队列
2. LinkedBlockingQueue
基于链表实现的阻塞队列
3. PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
4. TransferQueue
最多只包含一个元素的阻塞队列

<2>使用哈希表

使用HashMap的话那我们就不能保证线程安全,我们也不能使用HashTable,因为性能真的太低了。
那么我们使用什么呢?ConcurrentHashMap

1>HashTable

虽然我们不推荐使用,但是这里还是要说一下。
HashTable是给所有的方法都加上了synchronized,相当于锁住整个数组。如果多线程访问同一个 Hashtable 就会直接造成锁冲突。而且size 属性也是通过 synchronized 来控制同步, 也是比较慢的。一旦涉及到扩容,那就是大量元素的拷贝,是及其消耗资源和时间的。
这里提一下,HashTable的底层数据结构是数组加链表。

2>ConcurrentHashMap

ConcurrentHashMap相比于 Hashtable 做出了一系列的改进和优化。首先就是优化了底层的数据结构,它的实现是数组加链表加红黑树,

然后这里涉及到了几个操作,简略说一下。

读 操 作 \color{red}{读操作}

ConcurrentHashMap的属性,包括Node中的属性,都是使用volatile修饰的,我们知道volatile关键字保持了可见性和有序性,但是由于读操作本身就是线程安全的,所以就可以不加锁。

写 操 作 p u t ( K k , V v ) \color{red}{写操作put(K k,V v)} put(Kk,Vv)

写操作加锁的方式仍然是使用 synchronized, 但是不是锁整个对象, 而是 “锁桶” (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率.
写操作的整体思路如下:

<1>先通过k对象的hashcode来计算数组索引。
<2>在第一步计算的基础上,保存V对象。

那么就要分情况了,在考虑线程安全的基础上去保存对象

<1>如果当前对象为空,就相当于与数组[索引] = V,也就是CAS+自旋的操作。而且如果为空,
那就意味着不读取不到,满足大多数时间,没有线程冲突的cas条件。
<2>节点已经存在元素,就相当于链表/红黑树中,添加一个元素(synchronized(头结点)

这里多个线程对多个节点可以并发写,但是对一个节点的操作是互斥的。

扩 容 操 作 \color{red}{扩容操作}

如果当我们添加的内容超过我们的载荷因子的时候,就需要进行扩容了,这里的扩容方式类似于CopyOnWriteArrayList。都是采取新老两个数组,但是是采取一个化零为整的一个方式。

1.发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去. 
2.扩容期间, 新老数组同时存在. 
3.后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一
小部分元素. 
4.搬完最后一个元素再把老数组删掉. 
5.这个期间, 插入只往新数组加. 
6.这个期间, 查找需要同时查新数组和老数组

四丶死锁

什么是死锁?
死锁是一种很严重的Bug,当多个线程申请锁出现环路等待时,就会造成线程始终处于阻塞等待的情况。产生死锁的必要条件是四个:

1)互斥条件:进程对所分配到的资源进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
2)请求和保持条件:进程已经获得了至少一个资源,但又对其他资源发出请求,而该资源已被其他进程占有,此时该进程的请求被阻塞,但又对自己获得的资源保持不放。
3)不可剥夺条件:进程已获得的资源在未使用完毕之前,不可被其他进程强行剥夺,只能由自己释放。
4)环路等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。即存在一个处于等待状态的进程集合{Pl, P2, …, pn},其中 Pi 等待的资源被 P(i+1) 占有(i=0, 1, …, n-1),Pn 等待的资源被 P0占有。

<1>预防死锁

一般来说预防死锁的形成只要破坏形成死锁的四个条件中的任意一个就好了,但是互斥条件肯定是不能破坏的,所以我们可以从其他三个下手。

打破请求和保持条件:<1>采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待。 <2>每个进程提出新的资源申请前,必须先释放它先前所占有的资源。
打破不可剥夺条件:当进程占有某些资源后又进一步申请其他资源而无法满足,则该进程必须释放它原来占有的资源。
打破环路等待条件:实现资源有序分配策略,将系统的所有资源统一编号,所有进程只能采用按序号递增的形式申请资源。

<2>死锁相关代码展示

上面说了,那么我们就用实际的代码来演示一下,形成死锁的情况

public class Test {
    public static void main(String[] args) {
        Object lock1 = new Object();
        Object lock2 = new Object();
        Thread t1 = new Thread() {
            @Override
            public void run() {
                synchronized (lock1) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lock2) {
                        System.out.println("111");
                    }
                }
            }
        };
        t1.start();
        Thread t2 = new Thread() {
            @Override
            public void run() {
                synchronized (lock2) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lock1) {
                        System.out.println("222");
                    }
                }
            }
        };
        t2.start();
    }
}

这里两个线程在占有自己资源的同时都同时向对方发起了资源的请求,又同时都不愿意放手,就造成了死锁条件。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值