Java锁策略

目录

    • 1. 锁策略(基本概念)
      • 1.1 乐观锁 vs 悲观锁
      • 1.2 轻量级锁 vs 重量级锁
      • 1.3 自旋锁 vs 挂起等待锁
      • 1.4 互斥锁 vs 读写锁
      • 1.5 可重入锁 vs 不可重入锁(死锁)
        • 1.5.4. 死锁的四个必要条件(出现死锁了这4个条件缺一不可)
      • 1.6 公平锁 vs 非公平锁(先来后到)
      • 1.7. synchronized特点
    • 2. CAS(compare and swap)
      • 2.1 实现原子类 AtomicInteger
      • 2.2 实现自旋锁
      • 2.3 面试题:aba问题(版本号)
    • 3. synchronized锁策略
      • 3.1 锁升级
      • 3.2 锁消除(非必要不加锁)
      • 3.3 锁粗化
    • 4. JUC中常见的组件
      • 4.1 Callable(创建线程)
      • 4.2 ReentrantLock(与synchronized对比)
      • 4.3 semaphore信号量
      • 4.4 线程安全的集合类
        • HashTable和ConcurrentHashMap(面试题)

1. 锁策略(基本概念)

1.1 乐观锁 vs 悲观锁

锁的实现者,预测接下来锁冲突(就是锁竞争,2个线程针对1个对象加锁,产生阻塞等待)的概率是大还是不大,根据这个冲突的概率决定接下来应该怎么做。
乐观锁:预测接下来冲突概率不大。
悲观锁:预测接下来冲突概率比较大。
(乐观和悲观导致最终要做的事情不一样)
通常来说,悲观锁一般要做的工作更多一些,效率更低一些;乐观锁做的工作更少一点,效率更高一点。

1.2 轻量级锁 vs 重量级锁

轻量级锁:加锁解锁过程更高效。
重量级锁:加锁解锁过程更慢,更低效。
(和乐观悲观不是同一会事,但是有一定的重合)
一个乐观锁很可能也是一个轻量级锁(不绝对)
一个悲观锁很可能也是一个重量级锁(不绝对)

1.3 自旋锁 vs 挂起等待锁

自旋锁:是轻量级锁的一种典型实现。一旦锁被释放,就能第一时间拿到锁,速度更快(忙等,消耗cpu资源)。【通常纯用户态,不需要经过内核态,时间相对更短】
挂起等待锁:是重量级锁的一种典型实现。如果锁被释放,不能第一时间拿到锁,可能需要过很久才能拿到锁(好处:这个时间是空闲的,可以去学习别的技能)【通常内核的机制来实现挂起等待,时间更长了】

1.4 互斥锁 vs 读写锁

互斥锁:加锁就是单纯的加锁,没有更细化的区分
读写锁:能够把读和写两种加锁区分开。
synchronized是互斥锁,只有2个操作,①进了代码块加锁②出了代码块解锁。
读写锁有3个操作:①给读加锁②给写加锁【加锁操作分为了2种】③解锁;
读写锁中约定:

  • 读锁和读锁之间,不会锁竞争,不会产生阻塞等待(不会影响程序的速度,代码还是跑的很快)
  • 写锁和写锁之间,有锁竞争(减慢速度,但是保证准确性)
  • 读锁和写锁之间,有锁竞争(减慢速度,但是保证准确性)

1.5 可重入锁 vs 不可重入锁(死锁)

(看是否死锁,死锁就是没人能解锁)
可重入锁:一个锁在一个线程中连续加锁2次,不死锁
不可重入锁:一个锁在一个线程中连续加锁2次,死锁
sychronized是可重入锁,不会死锁,因为其在加锁的时候会判定一下,判定当前尝试申请锁的线程是不是已经就是锁的拥有者了,如果是直接放行。
【系统原生的锁、C++标准库的锁、Python的锁都是不可重入锁】
关于死锁的情况:

  1. 一个线程,一把锁,可重入锁没事,不可重入锁死锁;
  2. 两个线程,两把锁,即使是可重入锁也死锁;

例:t1线程先对locker1加锁,再对locker2加锁;
t2线程先对locker2加锁,再对locker1加锁;
此时,死锁。

  1. N个线程,N把锁 ——>哲学家就餐问题

这5个哲学家

  1. 随机的进行吃面(拿起筷子)和思考人生(放下筷子)
  2. 如果他想拿筷子被别人占用了就会等待,等的过程中不会放下手里已经拿到的筷子
    假设这5个哲学家,同时拿起左手边的筷子就死锁了。
1.5.4. 死锁的四个必要条件(出现死锁了这4个条件缺一不可)

①互斥使用:一个线程拿到一把锁之后,另一个线程不能使用(锁的基本特点)
②不可抢占:一个线程拿到锁,只能自己主动释放锁,不能被其他线程强行占有(不可以挖墙脚)(锁的基本特点)
③请求和保持:“吃着碗里的,惦记锅里的”(追到了1号,但是对2号跃跃欲试,也不放弃1号)(拿第2根筷子的时候不会放弃第1根筷子,请求第2根保持第1根)(代码的特点)
④循环等待:逻辑依赖是循环的(家钥匙锁车里了,车钥匙锁家里了)(代码的特点)

  1. 如何避免死锁这个bug
    破解上面4个条件之一
    破解循环等待这个条件,针对锁进行编号,如果需要同时获取多把锁,约定加锁顺序,先对小的编号加锁,后对大的编号加锁。
    只要约定了加锁顺序,循环等待自然破除,死锁就不会形成了。

1.6 公平锁 vs 非公平锁(先来后到)

公平锁:遵守先来后到
非公平锁:不遵守先来后到,此处等概率竞争是不公平的,因为此处定义“先来后到”是公平的。
系统对于线程的调度是随机的,synchronized这个锁是非公平锁,要想实现公平锁需要在synchronized的基础上加上队列来记录这些加锁线程的顺序。

1.7. synchronized特点

  1. 既是悲观锁,也是乐观锁;
  2. 既是轻量级锁,也是重量级锁;
  3. 轻量级锁基于自旋锁实现,重量级锁基于挂起等待锁实现;
  4. 是互斥锁,不是读写锁;
  5. 是可重入锁;
  6. 是非公平锁;
  7. synchronized会根据当前锁竞争的激烈程度自适应
    如果锁冲突不激烈,以轻量级锁/乐观锁的状态运行
    如果锁冲突激烈,以重量级锁/悲观锁的状态运行

2. CAS(compare and swap)

比较寄存器A和内存M的数据值,如果数值相同,就把寄存器B和内存M的数值进行交换。
更多时候,不关心寄存器中的数值是啥,更关心内存的数值,说是交换其实更关注内存M的内存值变换,相当于赋值操作。
CAS操作是一条CPU指令,原子的,实现了不加锁就保证线程安全。
基于CAS可以实现:

2.1 实现原子类 AtomicInteger

import java.util.concurrent.atomic.AtomicInteger;

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger num = new AtomicInteger(0);
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                num.getAndIncrement();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                num.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(num);
    }
}

运行结果:
Java锁策略_第1张图片
分析:这段代码没有加锁,但是线程安全,因为有一个compareAndSwapInt操作,并非是加锁,而是CAS,此处**CAS就是在确认当前value是不是变过,如果没变过才能自增,如果变过了就先更新再自增。**没有出现加锁,因此不涉及阻塞等待,所以比之前加锁的方案快很多。
Java锁策略_第2张图片

2.2 实现自旋锁

反复检查当前的锁状态,看是否解开了。
记录当前的锁被那个线程持有,如果当前owner是null,比较成功就把当前线程的引用设置到owner中,加锁成功;比较不成功,意味着owner非空,锁已经有线程持有了,此时CAS就啥都不干,直接返回false,循环继续进行,循环转的飞快,不停的尝试询问这里的锁是不是释放了。
好处:一旦锁释放,就立即能获取到。
坏处:CPU忙等。
一般乐观锁(锁冲突概率低)实现成自旋锁比较合适。

2.3 面试题:aba问题(版本号)

CAS关键是对比 内存和寄存器的值是否相同,通过这个对比来检测内存是不是改变过。
注意:万一对比的时候是相同的,但是不是没变过,而是a->b->a,此时就会有一定的概率出现问题。CAS只能对比值是否相同,不能确定这个值是否中间发生过改变。(大部分情况下都没事,小概率下会出bug)

问:如何解决aba问题?
答:aba关键是值会反复横跳,如果约定数据只能单方向变化(数据只能增加,或者只能减小),就可以解决。
问:如果需求要求该数值既能增加也能减小怎么办?
答:引入版本号,约定版本号只能增加,每次CAS对比的时候就不是对比数值本身,而是对比版本号。
只要约定版本号只能递增,就能保证此时不会出现aba反复横跳的问题,以版本号为基准,而不是以变量为基准了。

3. synchronized锁策略

3.1 锁升级

无锁->偏向锁->自旋锁->重量级锁
刚开始,偏向锁状态。(非必要不加锁,简单做个标记)
遇到锁竞争,就是自旋锁(轻量级锁)。
竞争更激烈,就会变成重量级锁(交给内核阻塞等待)。

3.2 锁消除(非必要不加锁)

编译阶段做的优化手段,检测当前代码是否多线程执行,是否有必要加锁。
如果没有必要加锁,但是把锁写了会再编译的过程中自动把锁去掉。

3.3 锁粗化

锁的粒度:synchronized代码块包含代码的多少,代码越多,粒度越粗,代码越少,粒度越细。
一般写代码希望锁的粒度更小一点,串行执行的代码少,并发执行的代码就多。
但是某个场景,要频繁加锁/解锁,此时编译器就可能把这个操作优化成一个更粗粒度的锁,因为每次加锁解锁都要有开销,尤其是释放锁之后,重新加锁,还需要重新竞争。

4. JUC中常见的组件

4.1 Callable(创建线程)

非常类似于Runnable(描述了一个任务,一个线程要干什么),描述了一个任务,一个线程要干啥,Runnable通过run方法描述,返回类型为void。但是call方法有返回值。
实现Callable可以创建线程
从1加到1000

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

public class ThreadDemo {
    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 = 0; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
        //还需要找个人来完成这个任务,Thread不能直接传callable,需要包装一层
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();

        System.out.println(futureTask.get()); //此处的get就是获取到上述任务call方法的返回值结果
        //get和join类似,都会阻塞等待
    }
}

4.2 ReentrantLock(与synchronized对比)

synchronized关键字是基于代码块的方式来控制加锁解锁的;ReentrantLock则是提供了lock和unlock独立的方法来进行加锁解锁,虽然大部分情况下使用synchronized就足够了,但是ReentrantLock也有单独的重要的作用。

  • synchronized只是加锁和解锁,加锁的时候如果发现锁被占用,只能阻塞等待;ReentrantLock提供了tryLock方法,如果加锁成功就加锁,如果加锁失败不会阻塞等待,而是直接返回false,让程序员更灵活决定接下来咋办。
  • synchronized是一个非公平锁(概率均等,不遵守先来后到),ReentrantLock提供了公平和非公平两种工作模式(再构造方法中传入true开启公平锁)
  • synchronized搭配wait和notify进行等待唤醒,如果多个线程wait同一个对象,notify的时候是随机唤醒一个;ReentrantLock搭配Condition这个类,这个类也可也起到等待通知,功能更强大。

4.3 semaphore信号量

本质上就是一个计数器,描述了当前“可用资源”的个数。
P操作,申请资源,计数器-1;
V操作,释放资源,计数器+1;
如果计数器是0,继续申请资源,就会资源等待。
典型场景:停场厂的车位剩余数量

4.4 线程安全的集合类

常用的集合类ArrayList、LinkedList、HashMap、PriorityQueue是线程不安全,如果在多线程环境下使用就可能会出现问题。
解决办法:

  1. 最直接的办法就是使用锁,手动保证
    多个线程去修改ArrayList此时就可能有问题,就可以给修改操作进行加锁
  2. 标准库提供了一些线程安全版本的集合类
    如果需要使用ArrayList可以使用Vector代替,Vector(上古时期的集合类)关键方法都是带有synchronized的。
HashTable和ConcurrentHashMap(面试题)

多线程使用哈希表,HashMap肯定是不行的。
HashTable是线程安全的,也是给关键方法加锁synchronized,加到方法上相当于是给this加锁。
但是ConcurrentHashMap才是推荐方案。
1. 加锁粒度的不同:触发锁冲突的频率,HashTable是针对整个哈希表加锁,任何的增删改查操作都会触发加锁,也就都可能会有锁竞争【其实没必要把锁加的这么勤快,因为有时候不存在线程安全的问题,但是由于synchronizd是加到this上,仍然会针对同一个对象产生锁竞争,产生阻塞等待】;ConcurrentHashMap不是只有一把锁了,每个链表(头结点)作为一把锁,每次进行操作都是针对对应链表的锁进行加锁,操作不同链表就是针对不同的锁加锁,此时不会有锁冲突,导致大部分加锁操作实际上没有锁冲突。【最大区别】
Java锁策略_第3张图片
上述情况是从Java8开始的,在Java1.7之前,ConcurrentHashMap使用“分段锁”,目的和上述是类似的,相当于好几个链表共用一把锁,这个设定不科学不高效,代码也复杂。
2. ConcurrentHashMap更充分利用了CAS机制,无锁编程,比如获取/更新元素个数等操作,就可以直接使用CAS完成,不必加锁。(CAS也能保证线程安全,往往比锁更高效,但是适用场景不像锁那么广泛)
3. ConcurrentHashMap优化了扩容策略,对于HashTable如果元素太多了,就会涉及到扩容,HashTable扩容需要重新申请内存空间,整体一次性搬运元素,如果元素过多,put操作就会非常卡顿;ConcurrentHashMap不会试图一次性把所有元素都搬运过去,而是每次搬运一部分,当put触发扩容,此时就会直接创建更大的内存空间,但是并不会直接把所有元素都搬运过去,而是每次只搬运一小部分,速度相当快,此时相当于同时存在2份Hash表了,此时插入元素直接往新表插入,删除元素在旧表删除,然后每次操作过程中都搬运一部分到新表过去。

你可能感兴趣的:(JavaEE,java,开发语言)