Java之各种锁,乐观锁、悲观锁、自旋锁、轻量级锁、偏向锁、重量级锁

本文转自:https://blog.csdn.net/zqz_zqz/article/details/70233767

 

锁的分类

锁从宏观上分类,分为悲观锁与乐观锁

乐观锁
  乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
  java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

悲观锁

  悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized

公平锁/非公平锁
公平锁和非公平锁的区别就在于,公平锁是FIFO机制,谁先来谁就在队列的前面,就能优先获得锁。非公平锁支持抢占模式,先来的不一定能得到锁。

package thread;

import java.util.ArrayList;
import java.util.concurrent.locks.ReentrantLock;

public class Test6 {
    public static class Service {
        public ReentrantLock fairLock = new ReentrantLock(true);

        public void testMethod() {
            try {
                fairLock.lock();
                System.out.println(Thread.currentThread().getName() + "拿到了锁");
            } finally {
                fairLock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        Service service = new Service();
        ArrayList<Thread> list = new ArrayList<>();
        Runnable runnable = new Runnable() {

            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + "开始运行");
                service.testMethod();

            }
        };
        for (int i = 0; i < 10; i++) {
            list.add(new Thread(runnable));
        }
        System.out.println(list);
        for (Thread thread : list) {
            thread.start();
        }
    }
}

----output----
[Thread[Thread-0,5,main], Thread[Thread-1,5,main], Thread[Thread-2,5,main], Thread[Thread-3,5,main], Thread[Thread-4,5,main], Thread[Thread-5,5,main], Thread[Thread-6,5,main], Thread[Thread-7,5,main], Thread[Thread-8,5,main], Thread[Thread-9,5,main]]
Thread-0开始运行
Thread-1开始运行
Thread-0拿到了锁
Thread-2开始运行
Thread-2拿到了锁
Thread-3开始运行
Thread-3拿到了锁
Thread-4开始运行
Thread-5开始运行
Thread-6开始运行
Thread-4拿到了锁
Thread-1拿到了锁
Thread-6拿到了锁
Thread-7开始运行
Thread-8开始运行
Thread-5拿到了锁
Thread-9开始运行
Thread-7拿到了锁
Thread-8拿到了锁
Thread-9拿到了锁
运行的顺序:0,1,2,3,4,5,6,7,8,9.
获得锁的顺序:0,2,3,4,1,6,5,7,8,9.

把true去掉改成非公平锁
----output----
[Thread[Thread-0,5,main], Thread[Thread-1,5,main], Thread[Thread-2,5,main], Thread[Thread-3,5,main], Thread[Thread-4,5,main], Thread[Thread-5,5,main], Thread[Thread-6,5,main], Thread[Thread-7,5,main], Thread[Thread-8,5,main], Thread[Thread-9,5,main]]

Thread-0开始运行
Thread-1开始运行
Thread-2开始运行
Thread-3开始运行
Thread-4开始运行
Thread-1拿到了锁
Thread-6开始运行
Thread-6拿到了锁
Thread-5开始运行
Thread-2拿到了锁
Thread-7开始运行
Thread-8开始运行
Thread-7拿到了锁
Thread-9开始运行
Thread-3拿到了锁
Thread-0拿到了锁
Thread-4拿到了锁
Thread-5拿到了锁
Thread-8拿到了锁
Thread-9拿到了锁


运行的顺序:0,1,2,3,4,5,6,7,8,9
拿到锁的顺序:1,6,2,7,3,6,4,5,8,9

上面的例子可以看出,公平锁的是有顺序的,非公平锁是无序的。同样除了重入锁,重入读写锁一样可以设置非公平和公平。

由重量级细化分为四种:自旋锁,轻量级锁,偏向锁,重量级锁

操作系统

用户态核心态

两个操作系统的概念,用户态,核心态
(1)当一个任务(进程)执行系统调用而陷入内核代码中执行时,称进程处于内核运行态(内核态)。
(2)当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。

线程阻塞的代价
  java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

  如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
  如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。

 

重量级锁

我们要进入一个同步、线程安全的方法时,是需要先获得这个方法的锁的,退出这个方法时,则会释放锁。如果获取不到这个锁的话,意味着有别的线程在执行这个方法,这时我们就会马上进入阻塞的状态,等待那个持有锁的线程释放锁,然后再把我们从阻塞的状态唤醒,我们再去获取这个方法的锁。

这种获取不到锁就马上进入阻塞状态的锁,我们称之为重量级锁

自旋锁

我们知道,线程从运行态进入阻塞态这个过程,是非常耗时的,因为不仅需要保存线程此时的执行状态,上下文等数据,还涉及到用户态内核态的转换。当然,把线程从阻塞态唤醒也是一样,也是非常消耗时间的。

刚才我说线程拿不到锁,就会马上进入阻塞状态,然而现实是,它虽然这一刻拿不到锁,可能在下 0.0001 秒,就有其他线程把这个锁释放了。如果它慢0.0001秒来拿这个锁的话,可能就可以顺利拿到了,不需要经历阻塞/唤醒这个花时间的过程了。

然而重量级锁就是这么坑,它就是不肯等待一下,一拿不到就是要马上进入阻塞状态。为了解决这个问题,我们引入了另外一种愿意等待一段时间的锁 --- 自旋锁

自旋锁就是,如果此时拿不到锁,它不马上进入阻塞状态,而是等待一段时间,看看这段时间有没其他人把这锁给释放了。怎么等呢?这个就类似于线程在那里做空循环,如果循环一定的次数还拿不到锁,那么它才会进入阻塞的状态。

至于是循环等待几次,这个是可以人为指定一个数字的。

自适应自旋锁

上面我们说的自旋锁,每个线程循环等待的次数都是一样的,例如我设置为 100次的话,那么线程在空循环 100 次之后还没拿到锁,就会进入阻塞状态了。

而自适应自旋锁它不需要我们人为指定循环几次,它自己本身会进行判断要循环几次,而且每个线程可能循环的次数也是不一样的。而之所以这样做,主要是我们觉得,如果一个线程在不久前拿到过这个锁,或者它之前经常拿到过这个锁,那么我们认为它再次拿到锁的几率非常大,所以循环的次数会多一些。

而如果有些线程从来就没有拿到过这个锁,或者说,平时很少拿到,那么我们认为,它再次拿到的概率是比较小的,所以我们就让它循环的次数少一些。因为你在那里做空循环是很消耗 CPU 的。

所以这种能够根据线程最近获得锁的状态来调整循环次数的自旋锁,我们称之为自适应自旋锁

轻量级锁

上面我们介绍的三种锁:重量级、自旋锁和自适应自旋锁,他们都有一个特点,就是进入一个方法的时候,就会加上锁,退出一个方法的时候,也就释放对应的锁。

之所以要加锁,是因为他们害怕自己在这个方法执行的时候,被别人偷偷进来了,所以只能加锁,防止其他线程进来。这就相当于,每次离开自己的房间,都要锁上门,人回来了再把锁解开。

这实在是太麻烦了,如果根本就没有线程来和他们竞争锁,那他们不是白白上锁了?要知道,加锁这个过程是需要操作系统这个大佬来帮忙的,是很消耗时间的,。为了解决这种动不动就加锁带来的开销,轻量级锁出现了。

轻量级锁认为,当你在方法里面执行的时候,其实是很少刚好有人也来执行这个方法的,所以,当我们进入一个方法的时候根本就不用加锁,我们只需要做一个标记就可以了,也就是说,我们可以用一个变量来记录此时该方法是否有人在执行。也就是说,如果这个方法没人在执行,当我们进入这个方法的时候,采用CAS机制,把这个方法的状态标记为已经有人在执行,退出这个方法时,在把这个状态改为了没有人在执行了。

之所以要用CAS机制来改变状态,是因为我们对这个状态的改变,不是一个原子性操作,所以需要CAS机制来保证操作的原子性。不知道CAS的可以看这篇文章:并发的核心:CAS 是什么?Java8是如何优化 CAS 的?

显然,比起加锁操作,这个采用CAS来改变状态的操作,花销就小多了。

然而可能会说,没人来竞争的这种想法,那是你说的而已,那如果万一有人来竞争说呢?也就是说,当一个线程来执行一个方法的时候,方法里面已经有人在执行了。

如果真的遇到了竞争,我们就会认为轻量级锁已经不适合了,我们就会把轻量级锁升级为重量级锁了。

所以轻量级锁适合用在那种,很少出现多个线程竞争一个锁的情况,也就是说,适合那种多个线程总是错开时间来获取锁的情况。

偏向锁

我们已经觉得轻量级锁已经够,然而偏向锁更加省事,偏向锁认为,你轻量级锁每次进入一个方法都需要用CAS来改变状态,退出也需要改变,多麻烦。

偏向锁认为,其实对于一个方法,是很少有两个线程来执行的,搞来搞去,其实也就一个线程在执行这个方法而已,相当于单线程的情况,居然是单线程,那就没必要加锁了。

不过毕竟实际情况的多线程,单线程只是自己认为的而已了,所以呢,偏向锁进入一个方法的时候是这样处理的:如果这个方法没有人进来过,那么一个线程首次进入这个方法的时候,会采用CAS机制,把这个方法标记为有人在执行了,和轻量级锁加锁有点类似,并且也会把该线程的 ID 也记录进去,相当于记录了哪个线程在执行。

然后,但这个线程退出这个方法的时候,它不会改变这个方法的状态,而是直接退出来,懒的去改,因为它认为除了自己这个线程之外,其他线程并不会来执行这个方法。

然后当这个线程想要再次进入这个方法的时候,会判断一下这个方法的状态,如果这个方法已经被标记为有人在执行了,并且线程的ID是自己,那么它就直接进入这个方法执行,啥也不用做

你看,多方便,第一次进入需要CAS机制来设置,以后进出就啥也不用干了,直接进入退出。

然而,现实总是残酷的,毕竟实际情况还是多线程,所以万一有其他线程来进入这个方法呢?如果真的出现这种情况,其他线程一看这个方法的ID不是自己,这个时候说明,至少有两个线程要来执行这个方法论,这意味着偏向锁已经不适用了,这个时候就会从偏向锁升级为轻量级锁。

所以呢,偏向锁适用于那种,始终只有一个线程在执行一个方法的情况哦。

 

锁优化

介绍3种简单的锁优化
这里写图片描述
这里写图片描述

 

 

所谓重入锁,指的是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以的。

synchronized 和   ReentrantLock 都是可重入锁。

可重入锁的意义在于防止死锁。

实现原理:通过为每个锁关联一个请求计数器和一个占有它的线程。当计数为0时,认为锁是未被占有的;线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数器置为1 。

如果同一个线程再次请求这个锁,计数将递增;每次占用线程退出同步块,计数器值将递减。直到计数器为0,锁被释放。

关于父类和子类的锁的重入:子类覆写了父类的synchonized方法,然后调用父类中的方法,此时如果没有重入的锁,那么这段代码将产生死锁(很好理解吧)。

例子:

比如说A类中有个方法public synchronized methodA1(){
        methodA2();

}

而且public synchronized methodA2(){
                    //具体操作

}

也是A类中的同步方法,当当前线程调用A类的对象methodA1同步方法,如果其他线程没有获取A类的对象锁,那么当前线程就获得当前A类对象的锁,然后执行methodA1同步方法,方法体中调用methodA2同步方法,当前线程能够再次获取A类对象的锁,而其他线程是不可以的,这就是可重入锁。

 

代码演示:

不可重入锁:

public class Lock{
    private boolean isLocked = false;
    public synchronized void lock() throws InterruptedException{
        while(isLocked){    
            wait();
        }
        isLocked = true;
    }
    public synchronized void unlock(){
        isLocked = false;
        notify();
    }
}
使用该锁:

public class Count{
    Lock lock = new Lock();
    public void print(){
        lock.lock();
        doAdd();
        lock.unlock();
    }
    public void doAdd(){
        lock.lock();
        //do something
        lock.unlock();
    }
}
当前线程执行print()方法首先获取lock,接下来执行doAdd()方法就无法执行doAdd()中的逻辑,必须先释放锁。这个例子很好的说明了不可重入锁。

可重入锁:

接下来,我们设计一种可重入锁

public class Lock{
    boolean isLocked = false;
    Thread  lockedBy = null;
    int lockedCount = 0;
    public synchronized void lock()
            throws InterruptedException{
        Thread thread = Thread.currentThread();
        while(isLocked && lockedBy != thread){
            wait();
        }
        isLocked = true;
        lockedCount++;
        lockedBy = thread;
    }
    public synchronized void unlock(){
        if(Thread.currentThread() == this.lockedBy){
            lockedCount--;
            if(lockedCount == 0){
                isLocked = false;
                notify();
            }
        }
    }
}
所谓可重入,意味着线程可以进入它已经拥有的锁的同步代码块儿。

我们设计两个线程调用print()方法,第一个线程调用print()方法获取锁,进入lock()方法,由于初始lockedBy是null,所以不会进入while而挂起当前线程,而是是增量lockedCount并记录lockBy为第一个线程。接着第一个线程进入doAdd()方法,由于同一进程,所以不会进入while而挂起,接着增量lockedCount,当第二个线程尝试lock,由于isLocked=true,所以他不会获取该锁,直到第一个线程调用两次unlock()将lockCount递减为0,才将标记为isLocked设置为false。

可重入锁的概念和设计思想大体如此,Java中的可重入锁ReentrantLock设计思路也是这样。

 

synchronized和ReentrantLock 都是可重入锁。ReentrantLock与synchronized比较:

1.前者使用灵活,但是必须手动开启和释放锁

2.前者扩展性好,有时间锁等候(tryLock( )),可中断锁等候(lockInterruptibly( )),锁投票等,适合用于高度竞争锁和多个条件变量的地方

3.前者提供了可轮询的锁请求,可以尝试去获取锁(tryLock( )),如果失败,则会释放已经获得的锁。有完善的错误恢复机制,可以避免死锁的发生。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值