锁的优化、JUC的常用类、线程安全的集合类

 目录

前言

一、synchronized的优化操作

1.1 锁膨胀/锁升级

1.2 锁消除

1.3 锁粗化

二、JUC

2.1 Callable接口

2.2 ReentrantLock类(可重入锁)

2.3 原子类

2.4 Semaphore类(信号量)

2.5 CountDownLatch类

三、线程安全的集合类

3.1 多线程使用顺序表 ArrayList

3.2 多线程环境使用队列

3.3 多线程环境使用哈希表 [重点 考点]

前言

这篇博客主要介绍 synchronized 的底层工作原理,包括:锁膨胀/锁升级、锁消除、锁粗化 ;并且介绍了 关于JUC的详细知识点 ;以及一些线程安全的集合类 ~

下面,正文开始 ......


一、synchronized的优化操作

由上一篇博客,我们可以知道,synchronized 使用的锁策略 有以下特点:

  1. 既是悲观锁,也是乐观锁(自适应)
  2. 既是轻量级锁,也是重量级锁(自适应)
  3. 轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁来实现
  4. 不是读写锁
  5. 是非公平锁
  6. 是可重入锁 

1.1 锁膨胀/锁升级

现在,我们需要知道 synchronized 是怎样 自适应的(这个过程,又叫做 锁膨胀/锁升级)~

synchronized 在加锁的时候要经历几个阶段:

  1. 当线程没有加锁的时候,这个阶段叫做 无锁
  2. 当线程刚开始加锁,并没有产生竞争的时候,这个阶段叫做 偏向锁
  3. 当线程进行加锁,并且已经产生锁竞争了,这个阶段叫做 轻量级锁
  4. 如果此时锁竞争的更激烈了,这个阶段叫做 重量级锁

 当然,这几个阶段,并不是说 每一次加锁都会一直加到 最后,可能会到某一阶段之后,就会解锁了;不会每一个阶段都会经历,但会经历到某一部分~

像上面的,锁的状态不断的升级,锁的竞争也逐渐加剧 的情况,就叫做 锁膨胀/锁升级~

这里就先重点介绍一下 偏向锁~

所谓偏向锁,并不是 "真正加锁",而是只使用一个标记表示 "这个锁是我的了",在遇到其他线程来竞争锁之前,会一直保持着这个状态~

直到真的有线程来竞争锁了,此时才会真正的加锁~

这个过程类似于 单例模式中的 "懒汉模式" —— 在必要的时候进行加锁,从而会节省一定的开销~

因为如果没有额外的线程 来参与锁竞争的话,那么就会一直处于偏向锁的状态,也就省去了加锁和解锁的开销了~ 


1.2 锁消除

synchronized 除了锁升级,还有其他的优化操作,比如说:锁消除 ~

所谓锁消除,即 编译器自动锁定,如果认为这个代码没必要加锁,就不加了。(在一些代码中,不涉及到线程安全问题,但是程序员还是加上了锁,编译器就会自动把不必要的锁去掉。)

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
//就比如说,StringBuffer 是线程安全的,它的 append 等方法,都是带有synchronized,
//如果上述代码 都只是在同一个线程里面执行的,那么此时就没有必要加锁了
//此时,JVM 就自动悄悄的把锁给去掉了(这也属于编译器优化的一个方面)

但是,锁消除的操作 不是所有的情况下都会触发,实际上,大部分情况下不能被触发~

而 向偏向锁 则是每一次加锁,都会进入到偏向锁的状态~


1.3 锁粗化

锁粗化,也是 synchronized 的一种优化方式 ~

在这之前,我们需要知道 锁的粒度 这个概念 ~

所谓 锁的粒度,指的是 synchronized 包含的代码范围是大还是小~

范围越大,粒度越粗 ;范围越小,粒度越细!!!

锁的粒度越细,能够更好的提高线程的并发,但是也会增加 "加锁解锁" 的次数 ~

锁粗化,也是类似的情况:它把一段代码中,频繁的加锁解锁,"粗化" 成一次加锁解锁了 ~

 毕竟,加锁解锁 也是需要开销的嘛 ~ 


二、JUC

所谓 JUC,实际上指的是:java.util.concurrent,它是一个包~

在这个包里面 存放了很多和多线程开发 的相关的类、接口~

2.1 Callable接口

Callable接口 和 前面的 Runnable 非常相似,都是可以在创建线程的时候,来指定一个 "具体的任务" ~

区别是:

Callable 指定的任务是带返回值的,Runnable 指定的任务是不带返回值的 ~

Callable 提供的 call 方法,可以方便的获取到代码的执行结果 ~ 

如果需要使用一个线程单独的计算计算出某个结果,此时使用Callable是比较合适的~

如:创建线程计算 1+2+3+ ... +1000,使用Callable版本 ~

代码示例:

package thread;
 
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
 
public class Demo29 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
 
        //套上一层,目的是为了获取到后续的结果
        FutureTask<Integer> task = new FutureTask<>(callable);
        Thread t = new Thread(task);
        t.start();
 
        //在 线程t 执行结果之前,get会阻塞,
        //直到 线程t 执行完了,结果算好了,get 才能返回,返回值就是 call方法 return 的内容
        //获取执行结果
        System.out.println(task.get());
    }
}

运行结果:

需要重点理解的是 下面一部分代码:

//给 计算结果 一个小票
FutureTask<Integer> task = new FutureTask<>(callable);

在线程需要计算结果的情况下,需要非常明确的知道 这个结果是谁来算的,不能像以前一样,直接把 callable 加到 Thread 的构造方法之中,需要套上一层其他的辅助类(之前使用 Runnable 的时候,不需要知道一个明确的结果):

就类似于,去餐馆吃饭的时候,人多的时候,老板会给一个小票,到时候就可以凭着小票来取餐,不至于搞乱了~


2.2 ReentrantLock类(可重入锁)

我们已经知道,synchronized 已经是一个可重入锁了,但是 为啥还要再搞一个 ReentrantLock 呢?

实际上,ReentrantLock 还是和 synchronized 之间有很大的差别的:

区别1、synchronized 是一个单纯的关键字,直接基于代码块为单位进行加锁解锁;ReentrantLock 则是一个类,更传统,提供 lock方法 加锁,unlock方法 解锁

 


区别2:ReentrantLock 在构造实例的时候,可以指定一个 fair参数 来决定锁对象是 公平锁还是非公平锁;true为公平锁,false为非公平锁;synchronized 加的锁只能是非公平锁,不能指定为公平锁~


区别3:对synchronized来说,提供的加锁操作就是死等,只要获取不到锁就会一直阻塞等待下去~;ReentrantLock 还提供了一个特殊的加锁操作 —— tryLock() 方法~


 区别4: ReentrantLock 提供了更加强大、更方便的 等待/唤醒 机制~


 synchronized是针对()里的对象,本质上是操作对象的“对象头”里的特殊的数据结构,这个部分是JVM内部C++实现的代码,此时都是在Java、代码层面上进行的锁对象的控制;ReentrantLock的锁对象就是你定义的ReentrantLock实例

 结论:虽然ReentrantLock 有一定优势,但大部分情况下,还是以 synchronized 为主,特殊场景下,才使用 ReentrantLock ~


2.3 原子类

原子类内部用的是 CAS 实现的,所以性能要比加锁实现 i++ 高很多 ~

原子类有以下几个:

  1. AtomicBoolean
  2. AtomicInteger
  3. AtomicInterArray
  4. AtomicLong
  5. AtomicReference
  6. AtomicStampedReference

 以 AtomicInteger 为例,常见方法有:

addAndGet(int delta);  <=>  i += delta;
decrementAndGet();     <=>  --i;
getAndDecrement();     <=>  i--;
incrementAndGet();     <=>  ++i;
getAndIncrement();     <=>  i++;

基于CAS,确实是更高效的解决了线程安全问题,但是CAS不能代替锁,CAS试用范围是有限的,不像锁适应范围那么广。 


2.4 Semaphore类(信号量)

所谓信号量,可以理解成 计数器,描述了可用资源的个数 ~

比如说,停车场的入口处,会有牌子(上面显示:当前有车位 N 个)~

当有车开进去以后,N 就会减去一个;当有车从出口开出去以后,N 就会加上一个 ~

这个 N 就叫做 信号量 ~

如果当前 N 已经是 0 了,如果还有车想进来,那就进不去了,新来的车只能阻塞等待,直到有车给开出去 ~

如上所示:

把车开进去,称之为 申请一个可用资源,信号量 -= 1,称为 P 操作 ;(acquire)

把车开出来,称之为 释放一个可用资源,信号量 +=1,称为 V 操作 ;(release)

其实,信号量 可以把它视为一个 更广义的锁,(锁是信号量的一种特殊情况,信号量是锁的一般表达)。实际开发中,虽然锁是最常用的,但信号量也是偶尔用到,主要还是看应用场景。

当信号量的取值是 0~1 的时候,就退化成了一个普通的锁 ~(这里的锁可以视为 计数器为1的信号量,二元信号量)

执行一次P操作,1->0;

执行一次V操作,0->1;

如果已经进行了一次P操作,继续进行P操作,就会阻塞等待。

锁 相当于是一个可用资源是 1 的信号量,只要加锁成功,其他的线程就获取不了 ~

但是,而信号量不一样,只要可用资源还有,就可以不断的进行 P 操作 ~  

代码中也是可以使用Semaphore来实现类似于锁的效果的,来保证线程安全。

我们需要知道,上面的信号量 +=1,-=1 都是原子的 ~

换句话说,Semaphore类 可以直接用与线程安全的控制 ~


代码演示:

package thread;
 
import java.util.concurrent.Semaphore;
 
public class Demo31 {
    public static void main(String[] args) throws InterruptedException {
        //构造方法传入有效资源的个数是 3 个
        Semaphore semaphore = new Semaphore(3);
        //P操作 申请资源,使用 acquire方法
        //每一次使用一个资源,信号量 -1
        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");
    }
}

运行结果:

 有效资源是 3 份,即使 想要申请4份资源,但是在运行结果中显示,最终只是申请了 3 份资源 ,第4份资源阻塞

只有前面的线程把资源释放了,才可以使用 ~


代码演示:

package thread;
 
import java.util.concurrent.Semaphore;
 
public class Demo31 {
    public static void main(String[] args) throws InterruptedException {
        //构造方法传入有效资源的个数是 3 个
        Semaphore semaphore = new Semaphore(3);
        //P操作 申请资源,使用 acquire方法
        //每一次使用一个资源,信号量 -1
        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");
        //V操作 释放资源,使用 release 方法
        //此时,信号量 = 0,线程进入阻塞,只有释放资源,线程才可以继续运行
        semaphore.release();
        System.out.println("释放资源成功");
        semaphore.acquire();
        System.out.println("申请资源成功");
        
    }
}

运行结果:


2.5 CountDownLatch类

CountDownLatch类,相当于 在一个大的任务被拆分成若干个子任务的时候,用这个来衡量 什么时候这些子任务都执行结束 ~(简单了解即可,用的不是很多,特殊场景使用的小工具)

举个例子:

此时在某地正在进行一场跑步比赛,只有当所有选手都到达终点的时候,裁判才可以吹哨结束比赛 ~

CountDownLatch 描述的就是啥时候所有选手都到达终点 ~

主要是两个方法:

  1. await(wait=等待,a=all全部)
  2. countDown 表示选手冲过终点线

 CountDownLatch在构造的时候,指定一个计数(选手个数)

代码示例:

package thread;
 
import java.util.concurrent.CountDownLatch;
 
public class Demo32 {
    public static void main(String[] args) throws InterruptedException {
        //模拟跑步比赛
        //构造方法中设定有 10 个选手参赛
        CountDownLatch latch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(() -> {
                try {
                    Thread.sleep(3000);
                    System.out.println(Thread.currentThread().getName()+" 到达终点");
                    //countDown 相当于 "撞线"
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            t.start();
        }
        //await 再等待所有的线程都 "撞线"
        //换句话说,调用 countDown 的次数达到初始化的时候 设定的值,
        //await 就返回,否则 await 就阻塞等待
        latch.await();
 
        System.out.println("比赛结束");
    }
}

运行结果:


三、线程安全的集合类

原来的集合类,大部分都是 线程不安全的 ~

换句话来说,大部分集合类在多线程环境下使用 都是有问题的 ~

ArrayList、LinkedList、TreeSet、TreeMap、HashSet、HashMap、Queue 是线程不安全的

Vector、Stack、HashTable 是线程安全的,关键方法带有 synchronized ~  


3.1 多线程使用顺序表 ArrayList

(一)自己使用加锁操作,自己使用synchronized或者ReentrantLock 【常见】

(二)使用 Collections.synchronizedList (new ArrayList);

synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List ~

synchronizedList 的关键操作上都带有 synchronized ~

使用这个方法把集合类套一层

(三)使用 CopyOnWriteArrayList

简称:COW,也叫写时拷贝

如果进行读操作,不做任何工作

如果进行写操作,就拷贝一份新的 ArrayList,针对新的进行修改

修改过程中如果有读操作,就继续读旧的这份数据,当修改完毕了,使用新的替换旧的(本质上就是一个引用之间的赋值,原子的) 这就叫做 写时拷贝 ~

虽然这种方案的优点是不用加锁,不过缺点是 拷贝成本可能会很高,所以 一般要求这个ArrayList不能太大~ 


3.2 多线程环境使用队列

多线程环境下常常使用以下阻塞队列:

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

3.3 多线程环境使用哈希表 [重点 考点]

HashMap本身不是线程安全的,在多线程环境下可以使用:

  1. HashTable(给关键方法加了synchronized )
  2. ConcurrentHashMap

考点:ConcurrentHashMap进行了哪些优化?比HashTable好在哪里?和HashTable之间的区别是啥?

优化1:细化锁粒度

最大的优化是:ConcurrentHashMap相比于HashTable大大缩小了锁冲突的概率,把一把大锁转化成多把小锁~

HashTable 直接搞了一个大锁(直接在方法上加synchronized ,等于是给this加锁),这就导致了 只要在多个线程去修改上面任意元素 的时候,都需要去进行加锁控制,就会有锁冲突,比较低效。

实际上,基于哈希表的结构特点,对于两个不同的哈希桶上的元素,不牵扯修改同一个变量,就不会发生线程安全问题(此时加锁的话,意义不大) 

但是,如果两个修改涉及到同一个哈希桶上,就会有线程安全问题 ~

于是,HashTable 就会显得低效 ~ 


相比之下,ConcurrentHashMap类 做出了重大的改进 —— 每个链表都有各自的锁,而不是大家同用一把锁了

 具体来说:该类是基于哈希表中的每一个链表对象进行加锁,线程需要对哪个链表对象进行操作,就在哪里加锁~(两个线程针对同一个锁对象加锁,才会有锁竞争,才会有阻塞等待,针对不同对象就没有锁竞争)

由于哈希表中链表数量很多,链表对象的元素个数较少,可以有效地降低锁竞争的概率 ~ 

 

 优化2:只给写操作加锁

ConcurrentHashMap针对读操作不加锁,只针对写操作才加锁

  1. 读和读之间没有冲突
  2. 写和写之间有冲突
  3. 读和写之间也没有冲突

在很多场景下,读写之间不加锁控制,可能会读到一个写了一半的结果,如果这时写操作不是原子的,读操作读到的就有可能是写到一半的数据,相当于脏读了~(可以用volatile+原子的写操作来解决)

优化3:使用 CAS 特性

ConcurrentHashMap内部充分利用CAS,来进一步的削减加锁操作的数量,比如维护元素的个数

优化4:针对扩容,化整为零

HashMap和HashTable的扩容方案是:

创建一个更大的数组空间,把旧数组上链表里的每个元素搬运到新数组上(删除+插入),这个扩容操作会在某次put的时候进行触发。如果元素个数特别多,就会导致这样的搬运操作比较耗时。

ConcurrentHashMap的扩容方案是:

每次搬运一小部分元素,创建新数组,旧数组也保留,每次put操作,都往新数组上添加,同时进行一部分搬运,每次get的时候,则旧数组和新数组都得查询,remove的时候,直接删元素即可,经过一段时间之后,所有元素都搬运好了,最终再释放旧数组。

 总结:ConcurrentHashMap 优化特点

  1. [ 最重要 ] 把锁的粒度细化,降低锁冲突的概率
  2. 有一个激进的操作:读没加锁,写才加锁
  3. 更充分的使用了 CAS 特性,达到一个更高效的操作(如 维护 size 的时候)
  4. 针对扩容场景进行了优化(化整为零,不会一口气的完成扩容;而是 每次基本操作,都扩容一点点,逐渐完成整个扩容 —— 不会特别卡)  
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值