一、乐观锁 & 悲观锁
1.1 乐观锁的定义
乐观锁,顾名思义,他比较乐观,他认为一般情况下不会出现冲突,所以只会在更新数据的时候才会对冲突进行检测。如果没有发生冲突直接进行修改,如果发生了冲突则不进行任何修改,然后把结果返回给用户,让用户自行处理。
1.1.1乐观锁的实现-CAS
乐观锁的实现并不是给数据加锁 ,而是通过CAS(Compare And Swap)比较并替换,来实现乐观锁的效果。
CAS比较并替换的流程是这样子的:CAS中包含了三个操作,单位:V(内存值)、A(预期的旧址)、B(新值),比较V值和A值是否相等,,如果相等的话则将V的值更换成B,否则就提示用户修改失败,从而实现了CAS机制。
这只是定义的流程,但是在实际执行过程中,并不会当V值和A值不相等时,就立即把结果返回给用户,而是将A(预期的旧值)改为内存中最新的值,然后再进行比较,直到V值也A值相等,修改内存中的值为B结束。
可能你还是觉得有些晦涩,那我们举个栗子:
看完这个图相信你一定能理解了CAS的执行流程了。
1.1.2 CAS的应用
CAS的底层实现是靠Unsafe类实现的,Unsafe是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(Native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定的内存数据。Unsafe类存在sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中的CAS操作的执行依赖于Unsafe类的方法。
注意Unsafe类的所有方法都是native修饰的,也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应的任务。因此不推荐使用Unsafe类,如果用不好会对底层资源造成影响。
为什么Atomic修饰的包装类,能够保证原子性,依靠的就是底层的unsafe类,我们来看看AtomicInteger的源码:
在getAndIncrement方法中还调用了unsafe的方法,因此这也就是为什么它能够保证原子性的原因。
因此我们可以利用Atomic+包装类实现线程安全的问题。
import java.util.concurrent.atomic.AtomicInteger; /** * 使用AtomicInteger保证线程安全问题 */ public class AtomicIntegerDemo { static class Counter{ private static AtomicInteger num=new AtomicInteger(0); private int MAX_COUNT=100000; public Counter(int MAX_COUNT){ this.MAX_COUNT=MAX_COUNT; } //++方法 public void increment(){ for (int i = 0; i < MAX_COUNT; i++) { num.getAndIncrement(); } } //--方法 public void decrement(){ int temp=0; for (int i = 0; i < MAX_COUNT; i++) { num.getAndDecrement(); } } public int getNum(){ return num.get(); } } public static void main(String[] args) throws InterruptedException { Counter counter=new Counter(100000); Thread thread1=new Thread(()->{ counter.increment(); }); Thread thread2=new Thread(()->{ counter.decrement(); }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println("最终结果:"+counter.getNum()); } }
1.1.3 CAS存在的问题
循环时间长,开销大
只能保证一个共享变量的原子性操作(可以通过循环CAS的方式实现)
存在ABA问题
1.1.4 ABA问题
什么时ABA问题呢?
比如说两个线程t1和t2,t1的执行时间为10s,t2的执行时间为2s,刚开始都从主内存中获取到A值,t2先开始执行,他执行的比较快,于是他将A的值先改为B,再改为A,这时t1执行,判断内存中的值为A,与自己预期的值一样,以为这个值没有修改过,于是将内存中的值修改为B,但是实际上中间可能已经经历了许多:A->B->A。
所以ABA问题就是,在我们进行CAS中的比较时,预期的值与内存中的值一样,并不能说明这个值没有被改过,而是可能已经被修改了,但是又被改回了预期的值。
import java.util.concurrent.atomic.AtomicInteger; /** * ABA问题演示 */ public class ABADemo1 { private static AtomicInteger money=new AtomicInteger(100); public static void main(String[] args) throws InterruptedException { //第一次点转账按钮(-50) Thread t1=new Thread(()->{ int old_money=money.get();//先得到余额 try {//执行花费2s Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } money.compareAndSet(old_money,old_money-50); }); t1.start(); //第二次点击转账按钮(-50) 不小心点击的,因为第一次点击之后没反应,所以不小心又点了一次 Thread t2=new Thread(()->{ int old_money=money.get();//先得到余额 money.compareAndSet(old_money,old_money-50); }); t2.start(); //给账户加50 Thread t3=new Thread(()->{ //执行花费1s try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } int old_money=money.get(); money.compareAndSet(old_money,old_money+50); }); t3.start(); t1.join(); t2.join(); t3.join(); System.out.println("最终的钱数:"+money.get()); } }
这个例子演示了ABA问题,A有100元,A向B转钱,第一次转了50元,但是点完转账按钮没有反应,于是又点击了一次。第一次转账成功后A还剩50元,而这时C给A转了50元,A的余额变为100元,第二次的CAS判断(100,100,50),A的余额与预期的值一样,于是将A的余额修改为50元。
1.1.5 ABA问题的解决方案
由于CAS是只管头和尾是否相等,若相等,就认为这个过程没问题,因此我们就引出了AtomicStampedReference,时间戳原子引用,在这里应用于版本号的更新。也就是我们新增了一种机制,在每次更新的时候,需要比较当前值和期望值以及当前版本号和期望版本号,若值或版本号有一个不相同,这个过程都是有问题的。
我们来看上面的例子怎么用AtomicStampedReference解决呢?
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicStampedReference; /** * ABA问题解决 添加版本号 */ public class ABADemo2 { private static AtomicStampedReferencemoney= new AtomicStampedReference<>(100,0); public static void main(String[] args) throws InterruptedException { //第一次点转账按钮(-50) Thread t1=new Thread(()->{ int old_money=money.getReference();//先得到余额 100 int oldStamp=money.getStamp();//得到旧的版本号 try {//执行花费2s Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } boolean result = money.compareAndSet(old_money, old_money - 50, oldStamp, oldStamp + 1); System.out.println(Thread.currentThread().getName()+"转账:"+result); },"线程1"); t1.start(); //第二次点击转账按钮(-50) 不小心点击的,因为第一次点击之后没反应,所以不小心又点了一次 Thread t2=new Thread(()->{ int old_money=money.getReference();//先得到余额 100 int oldStamp=money.getStamp();//得到旧的版本号 boolean result = money.compareAndSet(old_money, old_money - 50, oldStamp, oldStamp + 1); System.out.println(Thread.currentThread().getName()+"转账:"+result); },"线程2"); t2.start(); //给账户+50 Thread t3=new Thread(()->{ //执行花费1s try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } int old_money=money.getReference();//先得到余额 100 int oldStamp=money.getStamp();//得到旧的版本号 boolean result = money.compareAndSet(old_money, old_money + 50, oldStamp, oldStamp +1); System.out.println(Thread.currentThread().getName()+"发工资:"+result); },"线程3"); t3.start(); t1.join(); t2.join(); t3.join(); System.out.println("最终的钱数:"+money.getReference()); } }
AtommicStampedReference解决了ABA问题,在每次更新值之前,比较值和版本号。
1.2 悲观锁
什么是悲观锁?
悲观锁就是比较悲观,总是假设最坏的情况,每次去拿数据的时候都会认为别人会修改,所以在每次拿数据的时候都会上锁,这样别人想拿数据就会阻塞直到它拿到锁。
比如我们之前提到的synchronized和Lock都是悲观锁。
二、公平锁和非公平锁
公平锁: 按照线程来的先后顺序获取锁,当一个线程释放锁之后,那么就唤醒阻塞队列中第一个线程获取锁。
非公平锁: 不是按照线程来的先后顺序唤醒锁,而是当有一个线程释放锁之后,唤醒阻塞队列中的所有线程,随机获取锁。
之前在讲synchronized和Lock这两个锁解决线程安全问题线程安全问题的解决的时候,我们提过:
synchronized的锁只能是非公平锁;
Lock的锁默认情况下是非公平锁,而挡在构造 函数中传入参数时,则是公平锁;
公平锁:Lock lock=new ReentrantLock(true);
非公平锁:Lock lock=new ReentrantLock();
由于公平锁只能按照线程来的线程顺序获取锁,因此性能较低,推荐使用非公平锁。
三、读写锁
3.1 读写锁
读写锁顾名思义是一把锁分为两部分:读锁和写锁。
读写锁的规则是:允许多个线程获取读锁,而写锁是互斥锁,不允许多个线程同时获得,并且读操作和写操作也是 互斥的,总的来说就是读读不互斥,读写互斥,写写互斥。
为什么要这样设置呢?
让整个读写的操作到设置为互斥不是更方便吗?
其实只要涉及到“互斥”,就会产生线程挂起等待,一旦挂起等待,,再次被唤醒就不知道什么时候了,因此尽可能的减少“互斥"的机会,就是提高效率的重要途径。
Java标准库提供了ReentrantReadWriteLock类实现了读写锁。
ReentrantReadWriteLock.ReadLock类表示一个读锁,提供了lock和unlock进行加锁和解锁。
ReentrantReadWriteLock.WriteLock类表示一个写锁,提供了lock和unlock进行加锁和解锁。
下面我们来看下读写锁的使用演示~
import java.time.LocalDateTime; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantReadWriteLock; /** * 演示读写锁的使用 */ public class ReadWriteLockDemo1 { public static void main(String[] args) { //创建读写锁 final ReentrantReadWriteLock reentrantReadWriteLock=new ReentrantReadWriteLock(); //创建读锁 final ReentrantReadWriteLock.ReadLock readLock= reentrantReadWriteLock.readLock(); //创建写锁 final ReentrantReadWriteLock.WriteLock writeLock= reentrantReadWriteLock.writeLock(); //线程池 ThreadPoolExecutor executor=new ThreadPoolExecutor(5,5,0, TimeUnit.SECONDS,new LinkedBlockingDeque<>(100)); //启动线程执行任务【读操作1】 executor.submit(()->{ //加锁操作 readLock.lock(); try{ //执行业务逻辑 System.out.println("执行读锁1:"+ LocalDateTime.now()); TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } finally { readLock.unlock(); } }); //启动线程执行任务【读操作2】 executor.submit(()->{ //加锁操作 readLock.lock(); try{ //执行业务逻辑 System.out.println("执行读锁2:"+ LocalDateTime.now()); TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } finally { //释放锁 readLock.unlock(); } }); //启动线程执行【写操作1】 executor.submit(()->{ //加锁 writeLock.lock(); try { System.out.println("执行写锁1:"+LocalDateTime.now()); TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }finally { writeLock.unlock(); } }); //启动线程执行【写操作2】 executor.submit(()->{ //加锁 writeLock.lock(); try { System.out.println("执行写锁2:"+LocalDateTime.now()); TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }finally { writeLock.unlock(); } }); } }
根据运行结果我们看到,读锁操作是一起执行的,而写锁操作是互斥执行的。
3.2 独占锁
独占锁就是指任何时候只能有一个线程能执行资源操作,是互斥的。
比如写锁,就是一个独占锁,任何时候只能有一个线程执行写操作,synchronized、Lock都是独占锁。
3.3 共享锁
共享锁是指可以同时被多个线程获取,但是只能被一个线程修改。读写锁就是一个典型的共享锁,它允许多个线程进行读操作 ,但是只允许一个线程进行写操作。
四、可重入锁 & 自旋锁
4.1 可重入锁
可重入锁指的是该线程获取了该锁之后,可以无限次的进入该锁。
因为在对象头存储了拥有当前锁的id,进入锁之前验证对象头的id是否与当前线程id一致,若一致就可进入,因此实现可重入锁 。
4.2 自旋锁
自旋锁是指尝试获取锁的线程不会立即阻塞,而是采取循环的方式尝试获取锁,这样的好处是减少线程上下文切换的消耗。线程上下文切换就是从用户态—>内核态。
synchronized就是一种自适应自旋锁(自旋的次数不固定),hotSpot虚拟机的自旋机制是这一次的自旋次数由上一次自旋获取锁的次数来决定,如果上次自旋了很多次才获取到锁,那么这次自旋的次数就会降低,因为虚拟机认为这一次大概率还是要自旋很多次才能获取到锁,比较浪费系统资源。
审核编辑:刘清
全部0条评论
快来发表一下你的评论吧 !