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可以实现公平锁【先到先得】(默认是非公平的)。在构造的时候,传入一个简单的参数就成公平锁了。
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的每个哈希桶都有自己的锁,大大降低了锁冲突的概率,性能也就大大提高了
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)多个线程多把锁
典型的模型就是哲学家就餐问题,由迪杰斯特拉提出,具体问题如下:
有五位哲学家在一张圆桌上吃饭,这个圆桌上有五根筷子(黑色的部分)和一大份意大利面(蓝色的盘子),如果哲学家想要吃面就需要拿起自己左右手边的筷子来进行就餐,不饿的时候就思考人生。
大部分情况下,上边的模型是可以正常良好运转的,不会死锁,但是在极端情况下就会出现死锁了
假设五个哲学家同时拿起左手的筷子,并且这五个哲学家互相不谦让,此时就会陷入僵局
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就可以继续,这样就可以打破僵局