Java多线程进阶——JUC常见类和死锁

简介: java中的JUC就是java.util.concurrent包下的一些标准类或者接口,这个包里的东西都是和多线程相关的,以下就是这个包中常见的类和接口的用法及示例:

1.JUC常见类


java中的JUC就是java.util.concurrent包下的一些标准类或者接口,这个包里的东西都是和多线程相关的,以下就是这个包中常见的类和接口的用法及示例:


1.1 Callable 接口


这个接口类似于Runnable接口,只是Runnable描述的任务不带返回值,Callable描述的任务带返回值。

如果当前多线程需要完成的任务希望带上结果,使用Callable比较好。


代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 使用 Callable 版本。


public class Demo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //使用Callable定义一个任务
        Callable<Integer> callable=new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum=0;
                for (int i = 0; i <= 1000; i++) {
                    sum+=i;
                }
                return sum;
            }
        };
        FutureTask<Integer> futureTask=new FutureTask<>(callable);
        //创建一个线程,来执行上述任务
        //Thread的构造方法,不能直接传callable,还需要一个中间的类
        Thread t=new Thread(futureTask);
        t.start();
        //获取线程的计算结果
        //get方法会阻塞,直到call方法计算完毕,get才会返回
        System.out.println(futureTask.get());
    }
}


理解Callable


Callable和Runnable相对,都是描述一个“任务”。Callable描述的是带有返回值的任务,Runnable描述的是不带返回值的任务。


Callable通常需要搭配FutureTask来使用。FutureTask用来保存Callable的返回结果。因为Callable往往是在另一个线程中执行的,啥时候执行完并不确定。

FutureTask就负责等待结果出来的工作.


理解FutureTask


这个中间类就类似于我们去买饭时前台给你的小票,其存在的意义就是为了让我们能够获取到结果(是获取到结果的凭证)


学到此处我们可以再进行总结线程的创建方式:


1.继承Thread(可以使用匿名内部类,也可以不用)

2.实现Runnable(可以使用匿名内部类,也可以不用)

3.使用lambda

4.使用线程池

5.使用Callable


1.2 ReentrantLock


可重入锁,和synchronized定位类似,都是用来实现互斥效果来保证线程安全.


ReentrantLock的用法:


  • lock():加锁,如果获取不到锁就死等
  • tryLock(超时时间):加锁,如果获取不到锁,等待一定的时间以后就放弃加锁.
  • unlock():解锁


常见面试题:


谈谈synchronized和ReentrantLock的区别:

1(缺点):

如下代码所示,synchronized加锁后执行完包裹区域内的代码后自动解锁,而ReentrantLock在加锁后需要手动解锁,如果在加锁、解锁两行代码间有return或者出现了异常,就无法完成unlock解锁


ReentrantLock locker=new ReentrantLock();
 //加锁
 locker.lock();
 //解锁
 locker.unlock();


为了解决上述的问题,ReentrantLock的加锁解锁往往搭配try、catch、finall来使用.如下所示


ReentrantLock locker=new ReentrantLock();
try {
  //加锁
    locker.lock();
} finally {
    //解锁
    locker.unlock();
}


2(优点):


tryLock是尝试加锁,如果试成功了,就加锁成功;试失败了,就放弃,并且可以指定加锁的等待超时时间(在实际开发中,使用死等的策略往往要慎重,tryLock就给我们提供了更多的可能)


3(优点):


ReentrantLock可以实现公平锁【先到先得】(默认是非公平的)。在构造的时候,传入一个简单的参数就成公平锁了。


微信图片_20230111142655.png

4(优点):


synchronized是搭配wait/notify来实现等待通知机制的,唤醒操作时随机唤醒一个等待的线程。

ReentrantLock搭配Condition类实现唤醒操作,可以指定唤醒哪个等待的线程。


1.3 原子类


基于CAS实现的类,常用于多线程计数

见文章【Java多线程进阶——CAS与synchronized优化1.2.1】


1.4 线程池


见文章【Java多线程案例——线程池】


1.5 信号量 Semaphore


信号量,用来表示“可用资源的个数”,本质上就是一个计数器。


信号量的基本操作有两个:

P操作,申请一个资源,可用资源就-1

V操作,释放一个资源,可用资源就+1

当计数为0时,继续P操作,就会产生阻塞。阻塞等待到其他线程V操作了为止。


信号量可以视为是一个更广义的锁,锁就是一个特殊的信号量(可用资源只有1的信号量)


信号量的理解也可以类比为停车场:

在停车场门口挂着一个牌子:当前剩余车位数100

每次有车开进来就是P操作,剩余车位-1;每次有车开出去就是V操作,剩余车位+1;如果当前车位为0,还想往里面开开不进去,只能等或者是放弃。


代码示例:可用资源为4的信号量


public class Demo3 {
    public static void main(String[] args) throws InterruptedException {
        //构造的时候需要指定初始值,计数的初始值表示有几个可用的资源
        Semaphore semaphore=new Semaphore(4);
        //P操作申请资源,计数器-1
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
        //V操作释放资源,计数器+1
        semaphore.release();
    }
}
//
P操作
P操作
P操作
P操作


可以发现代码阻塞在了第五个acquire处,是因为可用资源为0时进行了阻塞。


1.6 CountDownLatch


CountDownLatch是一个同步工具类,同时等待N个任务执行结束

就像跑步比赛中,直到最后一名参数者到达终点时,比赛才结束。


代码示例:


public class Demo {
    public static void main(String[] args) throws InterruptedException {
        //有10个选手参加比赛
        CountDownLatch countDownLatch=new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            Thread t=new Thread(()-> {
                //创建10个线程来执行一批任务
                System.out.println("选手出发!"+Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("选手到达!"+Thread.currentThread().getName());
                //撞线
                countDownLatch.countDown();
            });
            t.start();
        }
        //await是进行阻塞等待,会等待到所有的选手都撞线以后,才能解除阻塞
        countDownLatch.await();
        System.out.println("比赛结束!");
    }
}
//运行结果:
选手出发!Thread-1
选手出发!Thread-2
选手出发!Thread-0
选手出发!Thread-6
选手出发!Thread-7
选手出发!Thread-3
选手出发!Thread-4
选手出发!Thread-8
选手出发!Thread-5
选手出发!Thread-9
选手到达!Thread-9
选手到达!Thread-1
选手到达!Thread-0
选手到达!Thread-2
选手到达!Thread-4
选手到达!Thread-6
选手到达!Thread-7
选手到达!Thread-5
选手到达!Thread-8
选手到达!Thread-3
比赛结束!
Process finished with exit code 0


这个类的应用场景也比较常见,比如使用多线程完成一个任务:需要下载一个很大的文件,就切分成多个部分,每个线程负责下载其中的一个部分,当所有线程都下载完毕,整个文件就下载完毕了。


1.7 线程安全的集合类


在多线程环境下使用以下集合:


1.7.1 ArrayList


在多线程环境下使用ArrayList是不安全的,解决的方法有三种:

1 是自己加锁(synchronized或者ReentrantLock);

2 是使用标准库提供的类Collections.synchronizedList(new ArrayList);

在其关键操作上都加锁了,这个做法有点简单粗暴

3是使用CopyOnWriteArrayList(写时拷贝),不加锁保证线程安全。


其实现原理为:在修改时并不会直接修改,而是把原来的数据给复制一份,在这个副本上完成修改后,原顺序表的引用指向该副本。


1.7.2 队列


1)ArrayBlockingQueue


基于数组实现的阻塞队列


2)LinkedBlockingQueue


基于链表实现的阻塞队列


3)PriorityBlockingQueue


基于堆实现的带优先级的阻塞队列


4)TransferQueue


最多只包含一个元素的阻塞队列


1.7.3 哈希表


HashMap本身是线程不安全的。

在多线程环境下使用哈希表可以使用:


Hashtable

ConcurrentHashMap

Hashtable并不推荐使用,因为其无脑的给各种方法加synchronized,推荐使用的是ConcurrentHashMap,因为其背后有很多的优化策略。


ConcurrentHashMap的优化策略如下:


1.锁粒度的控制


HashTable直接在方法上加synchronized,相当于是对this加锁(即相当于针对哈希表对象加锁),一个哈希表只有一个锁,多个线程无论怎样操作这个哈希表,都会产生锁冲突


而ConcurrentHashMap的每个哈希桶都有自己的锁,大大降低了锁冲突的概率,性能也就大大提高了


微信图片_20230111142648.png

2.只给写操作加锁,没有给读操作加锁


只有两个线程同时修改时,才会有锁冲突


如果两个线程读,没有锁冲突


如果一个线程读,一个线程修改,也没有锁冲突,但是这个操作是否有线程不安全的问题呢?

主要是考虑担心读到修改一半的数据,但是事实上ConcurrentHashMap设计的时候,考虑到了这一点,通过一些方法保证读到的数据一定是完整的(要么是旧版本的,要么是新版本的)


3.充分利用CAS的特性


比如像维护元素个数,都是通过CAS来实现,而不是加锁;包括还有些地方直接使用CAS实现的轻量级锁来实现。


4.对于扩容操作进行了特殊的优化


在HashTable扩容时,是发现了负载因子超过了阈值,需要申请一个更大的数组,然后把之前旧的数据给搬运到新的数组上(开销很大)


ConcurrentHashMap在扩容的时候,不是直接一次性完成搬运了,而是旧的和新的会同时存在一段时间,每次进行哈希表的操作,都会把旧的内存上的元素搬运一部分到新的空间上,直到最终搬运完成,此时再释放旧的空间。


2.死锁


2.1 死锁是什么?


死锁是指多个进程或线程在运行过程中因争夺资源而造成的一种僵局,当维持这种僵局状态时,程序都无法正常运行。


共有以下三种情景会造成死锁的状态:


1)一个线程一把锁

一个线程连续加锁两次,如果这个锁是不可重入锁,将会造成死锁;如果是可重入锁,则没这个问题


2)两个线程两把锁

就像家门钥匙锁车里了,车钥匙锁家里了这种情形


也有如下代码示例:


public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();
        Object locker2=new Object();
        Thread t1=new Thread(()-> {
            System.out.println("t1尝试获取locker1");
            synchronized (locker1) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("t1尝试获取locker2");
                synchronized (locker2) {
                    System.out.println("t1获取两把锁成功");
                }
            }
        });
        Thread t2=new Thread(()-> {
            System.out.println("t2尝试获取locker2");
            synchronized (locker2) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("t2尝试获取locker1");
                synchronized (locker1) {
                    System.out.println("t2获取两把锁成功");
                }
            }
        });
        t1.start();
        t2.start();
    }
}
//运行结果
t1尝试获取locker1
t2尝试获取locker2
t1尝试获取locker2
t2尝试获取locker1


造成死锁


3)多个线程多把锁

典型的模型就是哲学家就餐问题,由迪杰斯特拉提出,具体问题如下:


有五位哲学家在一张圆桌上吃饭,这个圆桌上有五根筷子(黑色的部分)和一大份意大利面(蓝色的盘子),如果哲学家想要吃面就需要拿起自己左右手边的筷子来进行就餐,不饿的时候就思考人生。


微信图片_20230111142644.png

大部分情况下,上边的模型是可以正常良好运转的,不会死锁,但是在极端情况下就会出现死锁了


假设五个哲学家同时拿起左手的筷子,并且这五个哲学家互相不谦让,此时就会陷入僵局


2.2 如何避免死锁


总结上述出现死锁的情况,共有以下四个必要条件:

1.互斥使用。锁A被线程1占用,线程2就用不了了。


2.不可抢占。锁A被线程1占用,线程2不能把锁A给抢回来,除非线程1主动释放。


3.请求和保持。有多把锁,线程1拿到锁A之后,不想释放锁A,还想拿到一个锁B。


4.循环等待。线程1等待线程2释放锁,线程2要释放锁得等待线程3释放锁,线程3释放锁得等待线程1释放锁。


解决死锁问题,就需要打破上述四个必要条件中的其中一个。第一个和第二个都是锁的基本特性,无法打破;第三个条件取决于代码的写法,是有可能打破的;第四个条件是有把握打破的,只要调整加锁顺序,就可以避免循环等待。


就像哲学家就餐问题,如果将5根筷子约定好编号12345,每位哲学家都先拿编号小的筷子,再拿编号大的筷子,这样就可以避免僵局


每位哲学家都先拿编号小的筷子再拿大的,5号哲学家拿不到1就等待,4号拿到两根筷子先吃,吃完后释放,3就可以继续,这样就可以打破僵局

微信图片_20230111142635.png



相关文章
|
2天前
|
Java 程序员 开发者
Java社招面试题:一个线程运行时发生异常会怎样?
大家好,我是小米。今天分享一个经典的 Java 面试题:线程运行时发生异常,程序会怎样处理?此问题考察 Java 线程和异常处理机制的理解。线程发生异常,默认会导致线程终止,但可以通过 try-catch 捕获并处理,避免影响其他线程。未捕获的异常可通过 Thread.UncaughtExceptionHandler 处理。线程池中的异常会被自动处理,不影响任务执行。希望这篇文章能帮助你深入理解 Java 线程异常处理机制,为面试做好准备。如果你觉得有帮助,欢迎收藏、转发!
33 14
|
5天前
|
安全 Java 程序员
Java 面试必问!线程构造方法和静态块的执行线程到底是谁?
大家好,我是小米。今天聊聊Java多线程面试题:线程类的构造方法和静态块是由哪个线程调用的?构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节有助于掌握Java多线程机制。下期再见! 简介: 本文通过一个常见的Java多线程面试题,详细讲解了线程类的构造方法和静态块是由哪个线程调用的。构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节对掌握Java多线程编程至关重要。
34 13
|
6天前
|
安全 Java 开发者
【JAVA】封装多线程原理
Java 中的多线程封装旨在简化使用、提高安全性和增强可维护性。通过抽象和隐藏底层细节,提供简洁接口。常见封装方式包括基于 Runnable 和 Callable 接口的任务封装,以及线程池的封装。Runnable 适用于无返回值任务,Callable 支持有返回值任务。线程池(如 ExecutorService)则用于管理和复用线程,减少性能开销。示例代码展示了如何实现这些封装,使多线程编程更加高效和安全。
|
6天前
|
安全 Java 编译器
JAVA泛型类的使用(二)
接上一篇继续介绍Java泛型的高级特性。3. **编译时类型检查**:尽管运行时发生类型擦除,编译器会在编译阶段进行严格类型检查,并允许通过`extends`关键字对类型参数进行约束,确保类型安全。4. **桥方法**:为保证多态性,编译器会生成桥方法以处理类型擦除带来的问题。5. **运行时获取泛型信息**:虽然泛型信息在运行时被擦除,但可通过反射机制部分恢复这些信息,例如使用`ParameterizedType`来获取泛型参数的实际类型。
|
6天前
|
安全 Java 编译器
JAVA泛型类的使用(一)
Java 泛型类是 JDK 5.0 引入的重要特性,提供编译时类型安全检测,增强代码可读性和可维护性。通过定义泛型类如 `Box&lt;T&gt;`,允许使用类型参数。其核心原理是类型擦除,即编译时将泛型类型替换为边界类型(通常是 Object),确保与旧版本兼容并优化性能。例如,`Box&lt;T&gt;` 编译后变为 `Box&lt;Object&gt;`,从而实现无缝交互和减少内存开销。
|
1月前
|
监控 Java
java异步判断线程池所有任务是否执行完
通过上述步骤,您可以在Java中实现异步判断线程池所有任务是否执行完毕。这种方法使用了 `CompletionService`来监控任务的完成情况,并通过一个独立线程异步检查所有任务的执行状态。这种设计不仅简洁高效,还能确保在大量任务处理时程序的稳定性和可维护性。希望本文能为您的开发工作提供实用的指导和帮助。
109 17
|
2月前
|
Java
Java—多线程实现生产消费者
本文介绍了多线程实现生产消费者模式的三个版本。Version1包含四个类:`Producer`(生产者)、`Consumer`(消费者)、`Resource`(公共资源)和`TestMain`(测试类)。通过`synchronized`和`wait/notify`机制控制线程同步,但存在多个生产者或消费者时可能出现多次生产和消费的问题。 Version2将`if`改为`while`,解决了多次生产和消费的问题,但仍可能因`notify()`随机唤醒线程而导致死锁。因此,引入了`notifyAll()`来唤醒所有等待线程,但这会带来性能问题。
Java—多线程实现生产消费者
|
1月前
|
缓存 安全 算法
Java 多线程 面试题
Java 多线程 相关基础面试题
|
6月前
|
存储 监控 Java
Java多线程优化:提高线程池性能的技巧与实践
Java多线程优化:提高线程池性能的技巧与实践
166 1
|
9月前
|
设计模式 监控 Java
Java多线程基础-11:工厂模式及代码案例之线程池(一)
本文介绍了Java并发框架中的线程池工具,特别是`java.util.concurrent`包中的`Executors`和`ThreadPoolExecutor`类。线程池通过预先创建并管理一组线程,可以提高多线程任务的效率和响应速度,减少线程创建和销毁的开销。
274 2

热门文章

最新文章