Java多线程之锁优化与JUC常用类

·  阅读 771

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第13天,点击查看活动详情


⭐️前面的话⭐️

本篇文章将介绍java多线程中有关synchronized关键字的优化手段和JUC中的常见类的使用。所谓的JUC指的就是java.util.concurrent包。

📒博客主页:未见花闻的博客主页
🎉欢迎关注🔎点赞👍收藏⭐️留言📝
📌本文由未见花闻原创!
📆掘金首发时间:🌴2022年6月6日🌴
✉️坚持和努力一定能换来诗与远方!
💭参考书籍:📚《操作系统》,📚《Java编程思想》,📚《Effective Java》
💬参考在线编程网站:🌐牛客网🌐力扣
博主的码云gitee,平常博主写的程序代码都在里面。
博主的github,平常博主写的程序代码都在里面。
🍭作者水平很有限,如果发现错误,一定要及时告知作者哦!感谢感谢!


🍀1.synchronized的优化手段

🍂1.1锁膨胀/升级

前面我们说过synchronized关键字加的锁既是轻量级锁也是重量级锁,它是根据实际情况自适应加锁的,这种自适应是基于锁膨胀或者说是锁升级这样的优化手段来实现的。

🌸锁升级过程:

  • 当没有线程加锁的时候,此时为无锁状态。
  • 当首个线程进行加锁的时候,此时进入偏向锁的状态,偏向锁不是真的加锁,而是在对象头做个标记而已,
  • 当有其他线程进行加锁,导致产生了锁竞争时,此时进入轻量级锁状态。
  • 如果竞争进一步加剧,进入重量级锁状态。

锁膨胀

像上面根据锁竞争的程度来逐步升级锁的情况,就是锁的膨胀或者称为锁的升级。

🍂1.2锁粗化

所谓锁粗化就是将synchronized的加锁代码块范围增大,加锁的代码块中的内容越多,锁就越粗,否则锁就越细。
一般我们认为,锁越细,多线程间的并发性越高,锁越粗,加锁解锁的开销就会更小。编译器会对你加的锁做一个优化,如果编译器判定加的锁过细,就会自动粗化,从而提高程序运行效率。

🍂1.3锁消除

有些代码,编译器认为没有加锁的必要,就会自动把你加的锁自动去除,像类似这样的优化,就是锁消除。

🍀2.java中的JUC

java中的JUC就是来自java.util.concurrent包下的一些标准类或者接口,都是有关并发或者有关多线程的一些类和接口。

🍂2.1Callable接口

前面我们创建线程的时候,有两种方式,一是继承Thred类并重写run方法来创建线程,二是通过Runnable接口来创建线程,除上述两种方式,我们还可以通过Callable接口配合FutureTask类来创建线程,使用该方法创建线程能够支持带返回值的任务,而最开始的那两种方法是不支持带回返回值的。

其中通过实现Callable接口的call方法来描述带有返回值的任务,FutureTask就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取返回值。必要时可以通过get方法获取执行结果(返回值),如果任务还没有执行完毕,该方法会阻塞直到任务返回结果。

在创建线程的时候,传入的引用不能是Callable类型,而应该是FutrueTask类型,根据Thread的构造方法,传入的任务类型需是Runnable类,CallableRunnable没有关系,而FutrueTask类实现了Runnable类,所以在此之前我们需要把实现Callable接口的对象引用传给FutrueTask类的实例对象。 Callable

FutureTask

综上,Callable用来描述任务,FutureTask类用来管理Callable任务的执行结果。

Thread构造方法 比如,现在我们需要使用线程来计算一个值,并通过返回值的方式获取执行结果。

🌸参考代码:

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

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                return 100 * (1 + 100) / 2;
            }
        };

        FutureTask<Integer> task = new FutureTask<>(callable);
        Thread thread = new Thread(task);
        thread.start();
        
        //获取执行结果
        System.out.println(task.get());
    }
}
复制代码

🌸运行结果:

5050

Process finished with exit code 0
复制代码

🍂2.2ReentrantLock类(可重入锁)

ReentrantLock其实就是可重入锁,使用方式是通过lock方法加锁,unlock方法解锁,注意加锁和解锁两个过程是分开的,而synchronized关键字加锁解锁是一步到位的。

由于加锁解锁两个操作是分开的,相比于加锁解锁一体化这就很容易造成死锁问题,这是因为一方面加锁后容易忘记去解锁,造成死锁,另一方面加锁后解锁前中间的代码万一出了问题,可能会导致解锁无法正常执行导致解锁失败,造成死锁。
所以使用ReentrantLock类时,一般要搭配finally使用。

ReentrantLock lock = new ReentrantLock(); 
//dosomething
lock.lock();   
try {    
 	// working    
} finally {    
 	lock.unlock()    
}
复制代码

🌸ReentrantLock类与synchronized关键字区别:

  • ReentrantLock是一个java标准类,是使用java代码实现的,synchronized是一个关键字,是基于JVM内部实现的,是C/C++代码。
  • ReentrantLock需要手动解锁,需谨防忘记解锁,而synchronized加锁解锁一体化,不需要手动解锁。
  • 如果出现锁竞争,ReentrantLock竞争失败时可以阻塞等待,也可以通过trylock方法直接返回退出,而synchronized竞争失败时只能阻塞等待。
  • ReentrantLock构造实例对象时,可以指定fair参数来决定该锁对象是公平锁还非公平锁,synchronized加的锁是非公平锁,不能指定为公平锁。
  • ReentrantLock类衍生出的等待机制是Condition类,synchronized关键字衍生的等待机制是wait/notify等待机制。

🍂2.3Semaphore类(信号量)

这个概念比较抽象,我们来打个比方,有个停车场,停车场门口有一个灯牌,会显示停车位还剩余多少个,每进去一辆车,显示的停车位数量就减一,每出去一辆出,显示的停车位数量就加一。

上面显示停车位数量的灯牌其实就是信号量,信号量是一更加广义的锁,描述了可用资源的个数。
每次申请一个可用资源,信号量中的计数器就减一(P操作)。
每次释放一个可用资源,信号量中的计数器就加一(V操作)。
当可用资源数量为0时,再次进行P操作,会陷入阻塞等待状态。

锁我们可以理解为“二元信号量”,因为计数器的取值不是0就是1,它的可用资源就一个。

🌸Semaphore类的常用方法:

序号方法方法类型作用
1public Semaphore(int permits)构造方法构造可用资源为permits个的信号量对象
2public Semaphore(int permits, boolean fair)构造方法相比于方法1,该构造方法还能指定信号量是否是公平性质的
3public void acquire() throws InterruptedException普通方法申请可用资源
4public void release()普通方法释放可用资源

🌸代码演示:

import java.util.concurrent.Semaphore;
public class Main {
    public static void main(String[] args) throws InterruptedException {
        //构造方法中的permits参数表示可用资源的个数
        Semaphore semaphore = new Semaphore(4);
        //每次使用一个可用资源,信号量就会减少1
        semaphore.acquire();
        System.out.println("申请成功");
        semaphore.acquire();
        System.out.println("申请成功");
        semaphore.acquire();
        System.out.println("申请成功");
        semaphore.acquire();
        System.out.println("申请成功");
        //此时可用资源为0,线程进入阻塞,需要使用release方法释放资源,线程才能继续执行
        semaphore.release();
        System.out.println("释放成功");
        semaphore.acquire();
        System.out.println("申请成功");
    }
}
复制代码

🌸执行结果:

申请成功
申请成功
申请成功
申请成功
释放成功
申请成功

Process finished with exit code 0
复制代码

🍂2.4CountDownLatch同步工具类

CountDownLatch是一个同步工具类,它允许一个或多个线程一直等待,直到其他线程执行完后再执行。
打个比方,假设有一场跑步比赛,一个有5个远动员参赛,只有当最后一个远动员冲过终点线时,裁判才能宣布比赛结束。

这里的运动员就相当于线程,裁判就相当于CountDownLatch类。

🌸CountDownLatch同步工具类常用方法:

序号方法方法类型作用
1public CountDownLatch(int count)构造方法构造实例对象,count表示CountDownLatch对象中计数器的值
2public void await() throws InterruptedException普通方法使所处的线程进入阻塞等待,直到计数器的值清零
3public void countDown()普通方法将计数器的值减1
4public long getCount()普通方法获取计数器最初的值

🌸使用方式:

  • 创建CountDownLatch对象,并初始化计数器的值。
  • 在每个线程执行的最后使用countDown方法,表示当前线程执行完毕,计数器的值减1。
  • 在主线程中使用await方法,等待CountDownLatch对象的计数器清零,表示所管理的线程全部执行完毕,起到线程同步的作用。

🌸参考代码:

import java.util.concurrent.*;
public class Main {
    public static final int COUNT = 5;
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(COUNT);

        for (int i = 0; i < COUNT; i++) {
            Thread thread = new Thread(() -> {
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName() + "任务执行完毕!");
                    countDownLatch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread.start();
        }
        //等待计数器清零,清零前,线程处于阻塞等待状态,清零后,即全部任务执行完毕
        countDownLatch.await();
        System.out.println("任务全部完成!");
    }
}
复制代码

这样的场景在实际开发当中,也是很常见的,比如要下载一个较大的文件的时候,常常将文件拆分,使用多线程并发下载。

而在这样一个场景中,需要等待最后一个线程也下载完毕,才能说整个文件下载完毕,也就是使用CountDownLatch对象进行计数,等计数器清零了await方法就会返回,表示文件下载完成。

🍂2.5有关数据结构的线程安全类

🍁2.5.1多线程使用顺序表

ArrayList在多线程中是线程不安全的,多线程环境中使用基于写实拷贝实现的CopyOnWriteArrayList

所谓写实拷贝,就是写的时候会创建一个副本,再副本上进行修改,同时如果存在读操作会在原文件数进行查询,等修改完毕后就会将副本“转正”。

🍁2.5.2多线程使用队列

🌸多线程情况下常常使用阻塞队列:

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

🍁2.5.3多线程使用哈希表

HashMap本身是线程不安全的,将HashMap中的重要方法使用synchornized加锁后,就得到了HashTable类,虽然HashTable类是线程安全的,但是由于是对方法进行无脑加锁,本质加锁的对象是HashTable类的实例对象,这样就会导致锁竞争概率加大,就相当于公司里所有的员工需要请假时都需要找老板签字批准,这样会导致老板非常地忙,这个老板就相当于加锁的哈希表对象,最终会造成哈希表的效率下降。 HashTable

为了解决这个问题,java提供了ConcurrentHashMap类,该类是基于哈希表中的每一个链表对象进行加锁,线程需要对哪个链表对象进行操作,就在哪里加锁,由于哈希表中链表数量很多,链表对象的元素个数较少,可以有效地降低锁竞争的概率,相当于公司中的老板将权力下放给各个部门,员工请假时只需向所在的部门领导请假即可。 ConcurrentHashMap 到这里,Java多线程有关内容基本上都介绍完毕了,不知道小伙伴们学会了多少呢?


下期预告:Java文件IO

分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改