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



相关文章
|
8天前
|
安全 Java
java 中 i++ 到底是否线程安全?
本文通过实例探讨了 `i++` 在多线程环境下的线程安全性问题。首先,使用 100 个线程分别执行 10000 次 `i++` 操作,发现最终结果小于预期的 1000000,证明 `i++` 是线程不安全的。接着,介绍了两种解决方法:使用 `synchronized` 关键字加锁和使用 `AtomicInteger` 类。其中,`AtomicInteger` 通过 `CAS` 操作实现了高效的线程安全。最后,通过分析字节码和源码,解释了 `i++` 为何线程不安全以及 `AtomicInteger` 如何保证线程安全。
java 中 i++ 到底是否线程安全?
|
6天前
|
存储 安全 Java
java.util的Collections类
Collections 类位于 java.util 包下,提供了许多有用的对象和方法,来简化java中集合的创建、处理和多线程管理。掌握此类将非常有助于提升开发效率和维护代码的简洁性,同时对于程序的稳定性和安全性有大有帮助。
33 17
|
1天前
|
存储 Java 程序员
Java基础的灵魂——Object类方法详解(社招面试不踩坑)
本文介绍了Java中`Object`类的几个重要方法,包括`toString`、`equals`、`hashCode`、`finalize`、`clone`、`getClass`、`notify`和`wait`。这些方法是面试中的常考点,掌握它们有助于理解Java对象的行为和实现多线程编程。作者通过具体示例和应用场景,详细解析了每个方法的作用和重写技巧,帮助读者更好地应对面试和技术开发。
19 4
|
3天前
|
存储 设计模式 分布式计算
Java中的多线程编程:并发与并行的深度解析####
在当今软件开发领域,多线程编程已成为提升应用性能、响应速度及资源利用率的关键手段之一。本文将深入探讨Java平台上的多线程机制,从基础概念到高级应用,全面解析并发与并行编程的核心理念、实现方式及其在实际项目中的应用策略。不同于常规摘要的简洁概述,本文旨在通过详尽的技术剖析,为读者构建一个系统化的多线程知识框架,辅以生动实例,让抽象概念具体化,复杂问题简单化。 ####
|
4天前
|
Java 开发者
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
19 4
|
2天前
|
Java 编译器 开发者
Java异常处理的最佳实践,涵盖理解异常类体系、选择合适的异常类型、提供详细异常信息、合理使用try-catch和finally语句、使用try-with-resources、记录异常信息等方面
本文探讨了Java异常处理的最佳实践,涵盖理解异常类体系、选择合适的异常类型、提供详细异常信息、合理使用try-catch和finally语句、使用try-with-resources、记录异常信息等方面,帮助开发者提高代码质量和程序的健壮性。
9 2
|
4天前
|
消息中间件 供应链 Java
掌握Java多线程编程的艺术
【10月更文挑战第29天】 在当今软件开发领域,多线程编程已成为提升应用性能和响应速度的关键手段之一。本文旨在深入探讨Java多线程编程的核心技术、常见问题以及最佳实践,通过实际案例分析,帮助读者理解并掌握如何在Java应用中高效地使用多线程。不同于常规的技术总结,本文将结合作者多年的实践经验,以故事化的方式讲述多线程编程的魅力与挑战,旨在为读者提供一种全新的学习视角。
24 3
|
6天前
|
存储 安全 Java
如何保证 Java 类文件的安全性?
Java类文件的安全性可以通过多种方式保障,如使用数字签名验证类文件的完整性和来源,利用安全管理器和安全策略限制类文件的权限,以及通过加密技术保护类文件在传输过程中的安全。
|
5天前
|
安全 Java 调度
Java中的多线程编程入门
【10月更文挑战第29天】在Java的世界中,多线程就像是一场精心编排的交响乐。每个线程都是乐团中的一个乐手,他们各自演奏着自己的部分,却又和谐地共同完成整场演出。本文将带你走进Java多线程的世界,让你从零基础到能够编写基本的多线程程序。
17 1
|
9天前
|
缓存 Java 调度
Java中的多线程编程:从基础到实践
【10月更文挑战第24天】 本文旨在为读者提供一个关于Java多线程编程的全面指南。我们将从多线程的基本概念开始,逐步深入到Java中实现多线程的方法,包括继承Thread类、实现Runnable接口以及使用Executor框架。此外,我们还将探讨多线程编程中的常见问题和最佳实践,帮助读者在实际项目中更好地应用多线程技术。
17 3