JUC常见类

发布于:2024-05-03 ⋅ 阅读:(27) ⋅ 点赞:(0)

背景

JUC是java.util.concurrent的简称,这个包里面存放的都是和多线程相关的类,在面试中非常的重要

目录

1.Callable接口

2.ReentrantLock

3.信号量Semaphore

4.CountDownLatch

5.集合的线程安全问题

1.Callable接口

1.1.认识Callable接口

(1)Callable接口,是用于描述一个线程任务。

(2)Callable接口和Runnable接口非常的类似。用来描述线程任务的时候,都是使用匿名内部类,但是Runnable接口是重写run方法,而Calleble接口是重写call方法

(3)Runnale描述的任务没有返回值,但是Callable描述的接口是有返回值的。所以Calleble的存在就会有它特别的意义,也能干一些Runnale不能干的事情

看完上面的内容,你就大概了解了Callable接口大概是干什么用的,下面就来学会如果使用吧。

1.2.使用Callable接口创造线程

通过线程完成的任务:将一个变量从0累加到5000,并且在线程外面打印出来。

为了凸显Callable接口的独特优势,我们先使用Runnable接口完成任务。

(1)Runnable接口

有下面两种写法,都是ok的;但是还是推荐第一种,第二种是为了和Callable做对比

第一种: 

    static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i=0;i<5000;i++) {
                    count++;
                }
            }
        });
        t.start();
        t.join();
        System.out.println(count);
    }

第二种:

    static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for(int i=0;i<5000;i++) {
                    count++;
                }
            }
        };
        Thread t = new Thread(runnable);
        t.start();
        t.join();
        System.out.println(count);
        
    }

Runnale接口完成任务的逻辑:

局限性非常的明显,就是只能使用全局变量,并且线程内的变量外部无法获取,提高了耦合。

所以,我们的Callable接口就登场了。

(2)Callable接口

代码:

 public static void main(String[] args) throws InterruptedException, ExecutionException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int count = 0;
                for(int i=0;i<5000;i++) {
                    count++;
                }
                return count;
            }
        };
        FutureTask futureTask = new FutureTask(callable);
        Thread t = new Thread(futureTask);
        t.start();
        t.join();
        System.out.println(futureTask.get());
    }

虽然这个代码相比Runnable的代码看起来复杂了一点,但是降低了耦合,提高了可阅读性,也称为“异步编程”

我们解释一下代码:

举我们生活中吃饭的例子:futureTask就类似我们拿到的待餐的小票,而小票里面的内容(参数)就描述了我们要吃的东西(要完成的任务);小票有两份,一份会交给后厨,一份则在你手中,过后就可以根据小票取餐(拿到返回值)。

这就是使用Callable接口创造线程的方式。

2.ReentrantLock

这是一个可重入锁,学习新知识第一步,先认识发音

下面只是简单的介绍即可,点到为止

2.1.ReentrantLock的使用

(1)不靠谱的加锁

 public static void main(String[] args) {
        ReentrantLock locker = new ReentrantLock();
        //1.加锁
        locker.lock();
        //2.内容
        
        //3.解锁
        locker.unlock();
    }

代码解释:

为什么说这种加锁的写法是不靠谱的呢?因为在中间写代码的内容里面,可能会出现一些意外,比如直接程序退出,或者抛出异常,而导致没有解锁。

所以,都是采取下面的写法

(2)靠谱的加锁

 public static void main(String[] args) {
        ReentrantLock locker = new ReentrantLock();//创建锁对象
        try {
            //1.加锁
            locker.lock();
            //2.内容
            
        }finally {
            //3.解锁
            locker.unlock();
        }
    }

我们借鉴异常章节的写法,将关锁操作写在finally内部;于是,无论代码怎么执行,finally内部的代码是一定会被执行到的,于是,就不会担心不解锁的操作了。

2.2.ReentratLock的优势

这里介绍的优势是相对sychronized来说的,但是我们日常写代码还是用sychronized就可以了,准没有问题。

(1)提供公平锁的选择

sychronized锁,是非公平锁;而ReentratLock却可以选择是否公平。

 public static void main(String[] args) {
        ReentrantLock locker1 = new ReentrantLock();//非公平锁
        ReentrantLock locker2 = new ReentrantLock(true);//公平锁
    }

通过参数,就可以选择是否公平

(2)tryLock操作

这是一个什么操作呢?比如我们的sychronized锁,当A线程拿到锁之后,B线程再想获取锁,就会阻塞等待,这个等待是死等。但是ReentratLock中的tryLock操作,本质上也是加锁操作,如果该锁被占用了,此时就会直接返回失败,不会阻塞等待,如果该锁没有被占用,则会加锁成功。下面介绍tryLock的使用。

 public static void main(String[] args) {
        ReentrantLock locker = new ReentrantLock();
        Thread t1 = new Thread(()->{
            if(locker.tryLock()) {
                System.out.println("A线程获取到锁");
            }else {
                System.out.println("A线程没有获取到锁");
            }
        },"A");
        Thread t2 = new Thread(()->{
            System.out.println(locker.tryLock());
            if(locker.tryLock()) {
                System.out.println("B线程获取到锁");
            }else {
                System.out.println("B线程没有获取到锁");
            }
        },"B");
        t1.start();
        t2.start();

    }

很明显,线程B抢不过线程A,但是不会阻塞等待,而是直接退出了。

这就是tryLock的操作,还有一种带参数的tryLock,也就是等待一定的时间获取不到锁,就返回。

public static void main(String[] args) throws InterruptedException {
        ReentrantLock locker = new ReentrantLock();
        Thread t1 = new Thread(()->{
            if(locker.tryLock()) {
                System.out.println("A线程获取到锁");
            }else {
                System.out.println("A线程没有获取到锁");
            }
        },"A");
        Thread t2 = new Thread(()->{
            try {
                if (locker.tryLock(300, TimeUnit.MILLISECONDS)) {
                    System.out.println("B线程没有获取到锁");
                } else {
                    System.out.println("B线程获取到锁");
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        },"B");
        t1.start();
        t2.start();

    }

A线程先拿到锁,此时B没有拿到,等待锁的释放;300ms内,A线程释放了锁,B线程也就拿到了锁。

(3)可唤醒指定线程

在sychronized中,是搭配wait和notify来等待和唤醒线程,这种唤醒是随机的;

但是ReentratLock中,是搭配Condition接口来指定唤醒线程的,这样就比上述的随机唤醒更强一些。

3.信号量Semaphore

3.1.信号量概念

(1)信号量,本质上就是一个计数器

(2)举例:例如底下停车场,当有一辆车进去后,门口牌子里面的内容:车位剩余容量就会-1;当有一辆车出库之后,车位容量+1,当没有车位时,就会阻塞等待。

(3)标准库中,提供了Semaphore这个类,来操作信号量。P操作:计数器-1,申请资源;V操作:计数器+1,释放资源。

3.2.信号量代码使用

(1)创建信号量对象

 Semaphore semaphore = new Semaphore(5);//设置计数器的容量

这就是创造了一个计数器,参数是这个计数器的最大容量(比如只能停五辆车);接下来就是要对这个就是计数器进行+1或者-1操作。

(2)类方法

方法签名 说明
void acquire() 使计数器+1
void release() 使计数器-1
public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(5);//设置计数器的容量
        int i=0;
        semaphore.acquire();
        System.out.println("P操作"+": "+i++);
        semaphore.acquire();
        System.out.println("P操作"+": "+i++);
        semaphore.acquire();
        System.out.println("P操作"+": "+i++);
        semaphore.acquire();
        System.out.println("P操作"+": "+i++);
        semaphore.acquire();
        System.out.println("P操作"+": "+i++);
        semaphore.acquire();
        System.out.println("P操作"+": "+i++);
        
    }

计数器容量只有5,但是此时进行了六次P操作,最后一次P操作就会阻塞等待

这就是计数器的简单使用。

因为,当计数器的容量只有1的时候,其实可以当成锁来使用,换句话说,锁就是一种特殊的信号量

使用信号量保证线程安全:

 static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(1);
        Thread t1 = new Thread(()->{
            try {
                for (int i = 0; i < 50000; i++) {
                    semaphore.acquire();//计数器+1
                    count++;
                    semaphore.release();//计数器-1
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        Thread t2 = new Thread(()->{
            try {
                for (int i = 0; i < 50000; i++) {
                    semaphore.acquire();//加锁
                    count++;
                    semaphore.release();//解锁
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t1.join();
        System.out.println(count);
    }

4.CountDownLatch

4.1.概念

(1)CountDownLatch是一个非常使用的工具类

(2)举例:有一些下载速度非常的快工具(比如:IDM),会将一个下载任务分成多个部分,每个部分由一个线程去执行,当所有的线程都完成任务之后,下载任务才算完成。用来判别所有线程任务完成的这个行为,就是countDownLach所做的。

(3)在代码中,我们可以使用该类去操作一些相关的代码

4.2.代码使用

(1)类对象的的创建

 CountDownLatch downLatch = new CountDownLatch(10);//参数表示拆分成的任务数

这样就创造出来了对象

(2)代码主体

 public static void main(String[] args) throws InterruptedException {
        CountDownLatch downLatch = new CountDownLatch(10);//参数表示拆分成的任务数
        for (int i = 0; i < 10; i++) {
            int id = i;
            Thread t = new Thread(()->{
                System.out.println(id+"线程开始执行");
                try {
                    Thread.sleep(300);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println(id+"任务执行完毕");
                downLatch.countDown();//执行一次,相当于完成一个任务
            });
            t.start();
        }
        downLatch.await();//等待10个任务完成,才执行后续的逻辑
        System.out.println("10个任务执行完毕!");
    }

5.集合的线程安全问题

这里介绍的集合,就是我们之前在数据结构部分学习到的各种集合,如:顺序表、链表等等

5.1.背景知识

(1)不安全的集合

ArrayList、LinkedList、Queue、HashMap等,大多数的集合在多线程下使用,都是不安全的

(2)比较安全的集合

Vector、Stack、Hashtable自带sychronized、所以在多线程下,大部分是安全的。

为什么说不是一定安全的呢?原因是:这些集合加锁的位置是在每个方法中,比如两个线程同时插入/删除数据时,是会阻塞等待,属于线程安全的;但是一个线程取数据,一个线程删除数据,是线程不安全的。

所以,线程是否安全,还是要分场景和情况讨论。

下面介绍一下集合在多线程下该如何使用

5.2.多线程下的ArrayList

我们知道,ArraryList是没有任何锁的,但是想要在多线程中去使用它,该如何做呢?我们只需要使用标准库提供封装好的ArrayList即可,当然,也可以自己加锁,但是下面不介绍了

(1)Collections.sychronizedList(new ArrayList)

 public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<>();
        Collections.synchronizedList(arrayList);
        arrayList.add(10);
    }

这样就将普通的ArrayList进行了一定的加锁操作

(2)CopyOnWriteArrayList

字面意思:在写数据的时候复制新的容器

写时拷贝的操作:当我们往一个容器里面添加元素时,不是向旧的容器添加;而是新创造出一个新的容器,把原始数据拷贝进行,最后添加新的原始。当添加完原始之后,再将旧容器的引用指向新的容器。

优点:在读多写少的场景下,性能很高,不需要加锁竞争

缺点:占用内存多、新写的数据不能第一时间被读取到

多线程下使用队列,我们这里也不介绍了,而是直接使用前面学习到的阻塞队列即可

5.3.多线程下的哈希表

(1)背景

在多线程下,HashMap是线程不安全的,Hashtable虽然有锁,但是线程也不一定安全,并且不推荐使用

所以标准库中引入了:ConcurrentHashMap,这个哈希表就是在Hashtable的基础上进行的优化。

Hashtable:对每个方法都加锁。

ConcurrentHashMap:使用锁桶的方式。

(2)ConcurrentHashMap的三处优化

第一:加锁方式,采取锁桶的方式

哈希表里面的第一层是一个”数组“,每个数组背后是一个链表;而前面的Hashtable是对数组加锁,这里的ConcurrentHashMap是对每个链表加锁。

如果多个线程同时插入数据,并且在不同的链表上,则就相当于没有加锁,这样极大的提高了插入的效率。

第二:操作size,采取CAS的方式

在哈希表的容量上面,即使操作的不是同一个链表,size却是同一个,所以在操作size时,采取CAS的方式

第三:扩容,采取拷贝到新空间

扩容一般发生在插入数据之后,如果数据量巨大,在插入数据后进行普通的扩容,效率就会非常的慢,就显得非常的卡顿。所以这里采取特殊的扩容手段。

ConcurrentHashMap在扩容的时候,搞两份空间,一份是扩容前的,另一份是扩容后的;接下来就会每次从旧的空间搬运一部分数据到新的空间。

在般的过程中,如果发生插入操作:则会插入到新的空间中;发生删除操作:新的空间和旧的空间都删除;查找操作:新的空间和旧的空间都要查找。

以上就是ConcurrentHashMap做出的三个优化操作


网站公告

今日签到

点亮在社区的每一天
去签到