理解Java锁机制

理解Java锁机制

1. synchronized

synchronized是JVM内部实现的锁机制,他是一种可重入的、互斥的、悲观的、非公平的同步锁。

synchronized的几种使用方式

  • 锁静态方法
public class TestThread {
    public static void main(String[] args) {
        test();
    }

    //同步静态方法,就是对当前类的该方法加锁,
    //不同对象只有一个线程能获取实例,不同对象也竞争锁
    public synchronized static void test(){
        System.out.println("thread");
        
    }
}
  • 锁普通方法
public class TestThread {
    Object lock = new Object();

    public static void main(String[] args) {
        new TestThread().test();
    }

    //修饰普通方法(非静态方法),就是对当前对象实例的这个方法加锁,
    //只有同一对象才竞争锁
    public synchronized void test() {
        System.out.println("thread");
    }
}
  • 锁类本身class
public class TestThread {
    public static void main(String[] args) {
        test();
    }

    
    public static void test() {
        //对代码块加锁,synchronized(class),执行到此处对当前类加锁
        //不同对象之间竞争锁
        synchronized(TestThread.class){
            System.out.println("thread");
        }
    }
}
  • 锁类实例this
public class TestThread {
    public static void main(String[] args) {
        new TestThread().test();
    }

    
    public void test() {
        //对代码块加锁,synchronized(this),执行到此处,对当前类实例加锁,
        //不同对象之间不竞争锁
        synchronized(TestThread.this){
            System.out.println("thread");
        }
    }
}
  • 锁对象实例变量
public class TestThread {
    Object lock = new Object();
    public static void main(String[] args) {
        new TestThread().test();
    }

    
    public void test() {
        //对代码块加锁,synchronized(lock),执行到此处,对当前变量对象加锁
        //同一变量对象lock竞争锁
        synchronized(lock){
            System.out.println("thread");
        }
    }
}

synchronized的实现原理

首先,我们来看上面对象锁使用方式所对应的部分类字符指令集:

在这里插入图片描述

上面有红框的地方就是获取锁和释放锁的指令。实际上Java使用了Monitor对象(C++实现)来实现,当前monitor信息存储在Java对象头里,头里面包含了两个部分-Mark Word和Klass Point:

  • Mark Word:自身运行时的数据,例如分代年龄和锁状态标志,这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Monitor

Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。在Java1.6以前,采用的是重量级锁,在Java1.6中引入了经轻量级锁和偏量锁来减少获取锁和释放锁带来的性能消耗,同时默认开启自旋锁。

2. lock

lock锁的实现是ReentrantLock类,内部实现默认是非公平锁,也可以根据构造函数开启公平锁。
构造函数:


    /**
     * 创建一个ReentrantLock实例,相当于调用ReentrantLock(false)
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * 创建一个带公平参数的ReentrantLock
     *
     * @param fair {@code true} 公平锁
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

构造函数中有个sync变量名对象,它实际上是Sync类对象,具体实现怎么加锁的就是在这个类中。公平锁(FairSync)和非公平锁(NonfairSync)都是这个类的子类。具体细节在下方公平锁和非公平锁详细分析

3. volatile关键字

volatile用来修饰变量,只保证可见性,不保证原子性

  • 可见性:在所有线程对该对象进行读操作的时候,获取的值是最新的
  • 原子性:在写操作中,读-写-读是可以看做一次原子操作

例如

private volatile int x = 1;

volatile可以保证所有字段读取是最新的值,但是当多个线程同时写入的时候,它将读-写分别视为一次单操作,所以当同时读取时是相同值,但是执行完计算操作后,同时写入,就不能保证是两次计算叠加的值被更新。

例如两个线程对x=x+1操作,一开始两个线程同时读取到x=1,然后各自线程执行完后均为x=1;这个时候同时去写入x,被更新后就为2,与期望的值3是不一样的。

说到这里,就要了解一下CPU为了优化、加快运算速度使用多线程执行运算,为了对多线程的支持,使用了多级缓存(寄存器)来为多个线程临时保存数据,如一级缓存、二级缓存…等。线程更新了变量值后,会先写入缓存中,然后在适当的时机更新主内存。

volatile字段会在变量更新后,马上将值写入到主内存中,这就保证了可见性,但是在写的时候,不会锁住当前对象,所以不会保证原子性。实际上这是一种乐观锁的实现。

3. 乐观锁-悲观锁(主线程锁不锁住同步资源)

乐观锁和悲观锁并不特指某个锁,而是一种加锁策略机制。

  • 乐观锁:自己在使用数据的时候,认为不会有其他线程修改数据,所以在使用的时候不会加锁。在更新的时候才去判断有没有其他线程更新了数据,如果没更新,就自己修改数据;已经被其他线程更新,就抛出失败或重试。例如CAS

  • 悲观锁:自己在使用数据的时候,认为有别的线程会修改当前数据,先将数据加锁。

乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。

CAS机制

CAS,全称Compare And Swap,比较并替换。CAS当中包含了三个基本操作数:

  • 内存地址V
  • 旧的预期值A
  • 要修改的新值B

更新一个变量值的时候,只有当变量的预期值A和内存地址V当中的实际值相同时才会将内存地址V修改为新值B。

在这里插入图片描述

再看原子类里面的操作

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    //获取并操作内存的数据。内部使用CAS实现
    private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();
    //存储value在AtomicInteger中的偏移量
    private static final long VALUE;

    static {
        try {
            VALUE = U.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (ReflectiveOperationException e) {
            throw new Error(e);
        }
    }

    //存储value在AtomicInteger中的偏移量。
    private volatile int value;
}

接下来我们来看自增函数incrementAndGet,实际使用的还是Unsafe的getAndAddInt方法

 public final int incrementAndGet() {
        return U.getAndAddInt(this, VALUE, 1) + 1;
    }

    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            //navite方法,使用do-while自旋   
            v = this.getIntVolatile(o, offset);
        } while(!this.compareAndSwapInt(o, offset, v, v + delta));

        return v;
    }

getAndAddInt()循环获取给定对象o中的偏移量处的值v,然后判断内存值是否等于v。如果相等则将内存值设置为 v + delta,否则返回false,继续循环进行重试,直到设置成功才能退出循环,并且将旧值返回。整个“比较+更新”操作封装在compareAndSwapInt()中,在JNI里是借助于一个CPU指令完成的,属于原子操作,可以保证多个线程都能够看到同一个变量的修改值。

问题
  • ABA问题:CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。jdk1.5提供了AtomicStampedReference来解决这一问题

  • 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。

  • 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。jdk1.5提供AtomicReference来解决原子性问题

悲观锁

上文讲的lock和synchronized都是悲观锁,在使用时先加上锁

public class TestThread {
    Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        new TestThread().test();
    }

    public void test() {
        //先加上锁
        lock.lock();
        
        lock.unlock();
    }


    public synchronized void test2(){
        
    }


4. 自旋锁和适应性自旋锁(均不阻塞线程,是否有自旋次数限制)

阻塞和唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步锁定的时间很短,如果使用阻塞(线程挂起)可能花费的时间太多。为了避免这种线程切换,只需要让当前线程多等待一会儿,这种操作我们称之为自旋。如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。


//AtomicInteger.class类

    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            //navite方法,使用do-while自旋 
            v = this.getIntVolatile(o, offset);
        } while(!this.compareAndSwapInt(o, offset, v, v + delta));

        return v;
    }

采用是do-while自旋方法来重复试探获取锁,这种只适合等待时间比较短的场景,因为自旋中是使用循环去试探获取锁,还是会占用CPU资源和执行时间,所以自旋不能代替阻塞。一般java中的自旋锁会在10次左右就放弃。

还有一种是适应性自旋锁,它和自旋锁的区别就是它不会固定循环次数,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

在自旋锁中,还有三种常见的锁形式:TicketLock、CLHlock和MCSlock

  • TicketLock: 线程想要竞争某个锁,需要先领一张ticket,然后监听flag,发现flag被更新为手上的ticket的值了,才能去占领锁

  • CLHlock:CLH锁是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。

  • MCSlock:和CLH锁不同的是,CLH是轮训前驱结点的锁状态,而MCSlock是轮训自己的锁状态,当前驱结点释放锁的时候,会更新当前结点的锁状态

5. 公平锁-非公平锁(是否允许插队)

公平锁和非公平锁的区别是,是否允许插队。接来下我们从ReentrantLock实现来分析两者的区别

    //FairSync.java公平锁
    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;
        }


    //NonfairSync.java非公平锁
    final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

区别就在于公平锁多了hasQueuedPredecessors方法,这个方法主要是判断当前线程是否位于同步队列中的第一个。所以可以看出,公平锁是按队列排队来获取锁,而非公平锁则不判断是否是在对头,都可以获取到锁,这就达到了一个插队的效果

6. 可重入锁-非可重入锁(是否同一线程可重复持有锁)

public class TestThread {
    public synchronized void test2() {
        System.out.println("2");
        test3();
    }

    public synchronized void test3() {
        System.out.println("3");
    }
}

我们来看,如果是非可重入锁,执行到test2()方法时,当前线程获取到锁,执行test3()时,由于当前线程已经获取到了锁,就获取不到锁,出现死锁。但是是可重入锁,test3()依然能获取到锁,然后继续执行。

ReentrantLock继承了中的Sync继承了AbstractQueuedSynchronizer,在其中维护了一个status状态来记录重入的次数,初始值为0。

当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。

释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。

7. 共享锁-排它锁(是否独占锁资源)

  • 共享锁:在多线程读取的时候共享读锁

  • 排它锁:在写操作进行时,独占当前锁资源

ReentrantReadWriteLock是一种典型的共享锁和排它锁的结合

//ReentrantReadWriteLock.java
public class ReentrantReadWriteLock
        implements ReadWriteLock, java.io.Serializable {
    private static final long serialVersionUID = -6992448646407690164L;
    /** 内部类提供读锁 */
    private final ReentrantReadWriteLock.ReadLock readerLock;
    /** 内部类提供写锁 */
    private final ReentrantReadWriteLock.WriteLock writerLock;
    /** 执行所有同步操作引擎 */
    final Sync sync;
}

//ReadLock
public static class ReadLock implements Lock, java.io.Serializable {
        private static final long serialVersionUID = -5992448646407690164L;
        private final Sync sync;

        /**
         * Constructor for use by subclasses
         *
         * @param lock the outer lock object
         * @throws NullPointerException if the lock is null
         */
        protected ReadLock(ReentrantReadWriteLock lock) {
            sync = lock.sync;
        }
}   

//WriteLock
public static class WriteLock implements Lock, java.io.Serializable {
        private static final long serialVersionUID = -4992448646407690164L;
        private final Sync sync;

        /**
         * Constructor for use by subclasses
         *
         * @param lock the outer lock object
         * @throws NullPointerException if the lock is null
         */
        protected WriteLock(ReentrantReadWriteLock lock) {
            sync = lock.sync;
        }
}

在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。

在最开始提及AQS的时候我们也提到了state字段(int类型,32位),该字段用来描述有多少线程获持有锁。

在独享锁中这个值通常是0或者1(如果是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。但是在ReentrantReadWriteLock中有读、写两把锁,所以需要在一个整型变量state上分别描述读锁和写锁的数量(或者也可以叫状态)。于是将state变量“按位切割”切分成了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。

8. 无锁-偏向锁-轻量级锁-重量级锁(锁级别)

jdk1.5之前,只有无锁状态和重量级锁,在jdk1.6之后,增加了偏向锁,轻量级锁。锁状态记录在对象头的Mark Word中

锁状态存储内容标志位
无锁状态对象的hashCode、对象分代年龄、是否是偏向锁(0)01
偏量锁偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)01
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10

无锁

就是没有对资源进行锁定,所有线程都能访问并修改同一个资源,但是只有一个线程能修改成功,其他线程都会抛出错误或者重试。

无锁的典型实现就是CAS机制,即修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

在大部分情况下,锁总是同一资源获得,如果像1.5之前使用重量级锁,会造成资源的浪费,如果只有一个线程访问同步锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

轻量级锁

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

整体的锁状态升级流程如下:

在这里插入图片描述

综上,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

总结

上面就是我简易分析的java锁相关机制,限于时间和个人水平,暂未进行更全面深层次的讲解。读者可根据情况去熟悉锁的底层原理,多阅读源码

本文部分摘录自https://mp.weixin.qq.com/s?__biz=MzU0OTE4MzYzMw==&mid=2247485524&idx=1&sn=2807a248ab60ce21b22dc07ec1b0ee0c&chksm=fbb281aaccc508bc404611ee11b057bf4b3e02fbbb2916c472fe586cf9ee989eab2be1c84e49&mpshare=1&scene=1&srcid=#rd
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值