Java多线程锁机制解剖详解-解决你心中的困惑,锁住你的心

一、锁是什么?为何使用锁

  • 锁就是一个类似于队列的数据结构(比单纯的队列要复杂一些),具体一点,当有多个线程并发访问对象时,如果对象已经被其他线程锁定,那么当前线程会有一个入队的操作,直到当前线程为空,才可以执行;
  • 锁(Lock)可以理解为对共享数据进行保护的一个许可证. 对于同一个许可证保护的共享数据来说,任何线程想要访问这些共享数据必须先持有该许可证. 一个线程只有在持有许可证的情况下才能对这些共享数据进行访问; 并且一个许可证一次只能被一个线程持有; 许可证线程在结束对共享数据的访问后必须释放其持有的许可证;
  • Java锁机制其实就是一种等待机制,将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据一次只能被一个线程访问,这样锁就可以用来保障线程安全了;
  • 一个线程在访问共享数据前必须先获得锁; 获得锁的线程称为锁的持有线程; 一个锁一次只能被一个线程持有;
  • 在并发环境下,多个线程会对同一个资源进行争抢,可能会导致数据不一致的问题。为了解决这一问题,需要通过一种抽象的锁来对资源进行锁定。

二、锁种类–面试中经常问到

图片

1、乐观锁

  • 乐观锁顾名思义,这种锁很乐观,加锁的条件很宽松,它认为大多数操作不会改变数据,所以不会上锁,在更新的时候借助版本号的机制判断数据是否更新。如果这个数据没被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作;
  • 乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。适合读比较多的场景,不加锁的特点大幅度提升性能;
  • 乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作;
  • java 中的乐观锁基本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败;图片

2、悲观锁

  • 悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会 block 直到拿到锁;
  • java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如 RetreenLock;

3、自旋锁

  • 自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗;
  • 线程自旋是需要消耗 cup 的,说白了就是让 cup 在做无用功,如果一直获取不到锁,那线程也不能一直占用 cup 自旋做无用功,所以需要设定一个自旋等待的最大时间;
  • 如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态;

4、公平锁(Fair)

  • 公平锁指的是锁的分配机制是公平的,通常先对锁提出获取请求的线程会先被分配到锁,ReentrantLock 在构造函数中提供了是否公平锁的初始化方式来定义公平锁;
  • 加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得;
图片

5、非公平锁(Nonfair)JVM 按随机、就近原则分配锁的机制则称为不公平锁,ReentrantLock 在构造函数中提供了是否公平锁的初始化方式,默认为非公平锁。非公平锁实际执行的效率要远远超出公平锁,除非程序有特殊需要,否则最常用非公平锁的分配机制;

优点:

  1.  非公平锁性能比公平锁高 5~10 倍,因为公平锁需要在多核的情况下维护一个队列;
  2. Java 中的 synchronized 是非公平锁,ReentrantLock 默认的 lock()方法采用的是非公平锁。可以通过构造方法更改为公平锁;

6、共享锁    

  • 能被多个线程同时获取、共享的锁。即多个线程都可以获取该锁,对该锁对象进行处理。典型的就是读锁——ReentrantReadWriteLock.ReadLock;
  • 即多个线程都可以读它,而且不影响其他线程对它的读,但是大家都不能修改它;

7、独占锁

  • 独享锁:该锁每一次只能被一个线程所持有;
  • 独享锁和共享锁都是通过AQS队列来实现的,通过实现不同的方法,来实现独享或者共享;

8、互斥锁

  • 在访问共享资源之前进行加锁操作,在访问完成之后进行解锁操作;
  • 加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前线程解锁。如果解锁时有一个以上的线程阻塞,那么所有该锁上的线程都会变成就绪状态,第一个变为就绪状态的线程又执行加锁操作,那么其他的线程又会进入等待;
  • 在这种方式下,只有一个线程能够访问被互斥锁保护的资源。举个形象的例子:多个人抢一个马桶;

9、读写锁

  • 读写锁既是互斥锁,又是共享锁,read模式是共享,write模式是互斥(排他锁);
  • 读写锁的三种状态:读加锁,写加锁,不加锁;
  • 一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。只有一个线程可以占有写状态的锁,但可以有多个线程同时占有读状态的锁,这就是它可以实现高并发的原因;
  • 当处于写状态锁时,任何想要尝试获得锁的线程都会被阻塞,直到写状态锁被释放;
  • 当处于读状态锁时,允许其他线程获得它的读状态锁,但是不允许获得它的写状态锁,直到所有线程的读状态锁被释放;
  • 为来避免想要尝试写操作的线程一直得不到写状态锁而处于线程饥饿,当读写锁感知到有线程想要获得写状态锁时,便会阻塞其后想要获得读状态的线程。所以读写锁非常适合多读少写的情况。

三、java中锁的具体实现-代码实操

Synchronized是基于JVM来保证数据同步的,而Lock则是在硬件层面,依赖特殊的CPU指令实现数据同步的;

Synchronized,它就是一个:非公平,悲观,独享,互斥,可重入的重量级锁;

ReentrantLock,它是一个:默认非公平但可实现公平的,悲观,独享,互斥,可重入,重量级锁;

ReentrantReadWriteLocK,它是一个,默认非公平但可实现公平的,悲观,写独享,读共享,读写,可重入,重量级锁

1、synchronized是一种互斥锁,一次只允许一个线程访问修饰的代码

图片

①方法声明时使用,放在范围操作符(public等)之后,返回类型声明(void等)之前;

这时,线程获得的是成员锁,即一次只能有一个线程进入该方法,其他线程要想在此时调用该方法,只能排队等候,当前线程(就是在synchronized方法内部的线程)执行完该方法后,别的线程才能进入


    public synchronized void test(){
    }
     public synchronized static void method1(){    
       }


②对某一代码块使用
,synchronized后跟括号,括号里是变量,这样,一次只有一个线程进入该代码块.此时,线程获得的是成员锁.例如:


public int test1(int name){
 synchronized(name) {
  //一次只能有一个线程进入
 }
}

③synchronized后面括号里是一对象,此时,线程获得的是对象锁.例如:


//同步方法块,锁是括号里的对象
public class Countrys {
 public void setNames(String names) {
  synchronized (this) {
   System.out.println(names);
  }
 }
}

class TestLock {
  TestClass x, y;
  Object xlock = new Object(), ylock = new Object();
  public void foo() {
   synchronized(xlock) {
     //access x here
   }
   synchronized(ylock) {
     //access y here
   }
  }
  public void bar() {
   synchronized(this) {
     //access both x and y here
   }
  }
 }

④synchronized后面括号里是类,此时,线程获得的是对象锁


class CountryAges{
 private static long testNums = 0;
 private long lock_order;
 public CountryAges(){
  synchronized(CountryAges.class) {//-----这里
   testNums++;       // 锁数加 1。
   lock_order = testNums; // 
  }
 }
 }

2、ReentrantLock的使用

①ReentrantLock的字面意思是可重入锁,可重入的意思是线程可以同时多次请求同一把锁,而不会自己导致自己死锁。使用时最标准用法是在try之前调用lock方法,在finally代码块释放锁,如下所示:


public class ReDemo {
   ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
lock.lock();
try {
  while(条件判断表达式) {
      condition.wait();
  }
 // 处理逻辑
} finally {
    lock.unlock();
}
}

②AQS介绍

AQS(AbstractQueuedSynchronized )抽象队列式的同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock,下图是一个简单的模式图:

图片

AQS有一个 volatile int state的变量(代表共享资源,volatile的作用主要在于当前线程改变valotile修饰的变量后,该变量对于其他线程具有可见性)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列,本质是一个双向链表,每个节点保存一个阻塞的线程,多线程竞争有限的资源,对线程阻塞排列);

总得来说,state初始化为0,表示未锁定状态。线程A如果能拿到锁,0→1,设定当前线程为独享锁;否则,进入等待队列;

③非公平锁实现

在非公平锁中,每当线程执行lock方法时,都尝试利用CAS把state从0设置为1


static final class NonfairSync extends Sync {
    /**
     * Performs lock.  Try immediate barge, backing up to normal
     * acquire on failure.
     */
    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }
    public final void acquire(int arg) {    
        if (!tryAcquire(arg) && 
                acquireQueued(addWaiter(Node.EXCLUSIVE), arg))       
          selfInterrupt();
    }
    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}

④公平锁实现

在公平锁中,每当线程执行lock方法时,如果同步器的队列中有线程在等待,则直接加入到队列中


static final class FairSync extends Sync {
    private static final long serialVersionUID = -3000897897090466540L;
    final void lock() {
        acquire(1);
    }
    /**
     * Fair version of tryAcquire.  Don't grant access unless
     * recursive call or no waiters or is first.
     */
    protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (!hasQueuedPredecessors() &&
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
}

此外,ReetrantLock还提供了其它功能,包括定时的锁等待、可中断的锁等待、公平性、以及实现非块结构的加锁、Condition,对线程的等待和唤醒等操作更加灵活,一个ReentrantLock可以有多个Condition实例,所以更有扩展性,不过ReetrantLock需要显示的获取锁,并在finally中释放锁,否则后果很严重。

3、ReentrantReadWriteLock的使用详解
ReentrantReadWriteLock被大量使用在缓存中,因为缓存中的对象总是被共享大量读操作,偶尔修改这个对象中的子对象,比如状态,我们可以使用ReentrantReadWriteLock对根对象中生命周期短的子对象在内存中直接更新,不必依赖数据库锁,这又是一个摆脱数据库锁的进步;

ReentrantReadWriteLock会使用两把锁来解决问题,一个读锁,一个写锁

线程进入读锁的前提条件:

  •     没有其他线程的写锁;
  •     没有写请求或者有写请求,但调用线程和持有锁的线程是同一个;

线程进入写锁的前提条件:

  •     没有其他线程的读锁;
  •     没有其他线程的写锁;

//初始化读锁和写锁
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); 
ReentrantReadWriteLock.ReadLock rlock = rwl.readLock();
ReentrantReadWriteLock.WriteLock wlock = rwl.writeLock();
//加解锁
rlock.lock();
wlock.lock();

总结:

  • synchronized特点:保证内存可见性、操作原子性;
  • synchronized影响性能的原因:加锁解锁操作需要额外操作;互斥同步对性能最大的影响是阻塞的实现,因为阻塞涉及到的挂起线程和恢复线程的操作都需要转入内核态中完成(用户态与内核态的切换的性能代价是比较大的);
  • 使用ReentrantReadWriteLock可以推广到大部分读,少量写的场景,因为读线程之间没有竞争,所以比起sychronzied,性能好很多。
阅读原文

简介:一个有10多年经验开发的android、java、前端等语言的老程序员,在这里一起聊聊技术,一起聊聊生活、一起聊聊中年危机的生存之道,一起进步一起加油,感兴趣的欢迎订阅;不定时的更新。欢迎关注微信公众号:Android开发编程
(0)
打赏 喜欢就点个赞支持下吧 喜欢就点个赞支持下吧

声明:本文来自“Android开发编程”,分享链接:https://www.zyxiao.com/p/292401    侵权投诉

网站客服
网站客服
内容投稿 侵权处理
分享本页
返回顶部