谈谈java中的锁

谈谈java中的锁

写在前面
  在目前的编程潮流中,并发编程是一个重要的方向。谈到并发,自然就能想到java中多线程和锁。众所周知,多线程帮助程序提高了性能,比如一件事1个人干需要十小时,而十个人干只需要1小时。但是提高了程序性能的同时,安全性问题也就随之而来。比如买票,一个线程已经买了最后一张票,但并没有修改票的状态,此时恰好另一个线程也要买这张票,发现票未售空,也发起了买票动作。这时候就发生了一票两卖的情况。为了避免这种情况,java引入了锁的概念,在资源上加上锁,也就是在这种票上加上锁,只有拥有这个锁的线程才可以操作,这样就保证数据的一致性。下面来具体总结下锁的相关知识:
  锁按照功能可分为乐观锁、悲观锁、可重入锁、共享锁、独占锁等等。下面看看每种锁的特点。
  悲观锁指的是数据对外界的修改采取保守策略,它认为线程很容易会把数据修改掉,因此在整个数据被修改的过程中都会采取锁定状态,直到一个线程使用完,其他线程才可以继续使用。
举例来说关键字synchronized 就属于独占式悲观锁,是通过 JVM 隐式实现的,synchronized 只允许同一时刻只有一个线程操作资源。
  乐观锁和悲观锁的概念恰好相反,乐观锁认为一般情况下数据在修改时不会出现冲突,所以在数据访问之前不会加锁,只是在数据提交更改时,才会对数据进行检测。
  Java 中的乐观锁大部分都是通过 CAS(Compare And Swap,比较并交换)操作实现的,CAS 是一个多线程同步的原子指令,CAS 操作包含三个重要的信息,即内存位置、预期原值和新值。如果内存位置的值和预期的原值相等的话,那么就可以把该位置的值更新为新值,否则不做任何修改。
  CAS 可能会造成 ABA 的问题,ABA 问题指的是,线程拿到了最初的预期原值 A,然而在将要进行 CAS 的时候,被其他线程抢占了执行权,把此值从 A 变成了 B,然后其他线程又把此值从 B 变成了 A,然而此时的 A 值已经并非原来的 A 值了,但最初的线程并不知道这个情况,在它进行 CAS 的时候,只对比了预期原值为 A 就进行了修改,这就造成了 ABA 的问题。
  ABA 的常见处理方式是添加版本号,每次修改之后更新版本号,拿上面的例子来说,假如每次移动箱子之后,箱子的位置就会发生变化,而这个变化的位置就相当于“版本号”,当某人进来之后发现箱子的位置发生了变化就知道有人动了手脚,就会放弃原有的计划,这样就解决了 ABA 的问题。

public class AtomicStampedReference<V> {
    private static class Pair<T> {
        final T reference;
        final int stamp; // “版本号”
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }
    // 比较并设置
    public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp, // 原版本号
                                 int newStamp) { // 新版本号
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
    }
    //.......省略其他源码
}

  可以看出它在修改时会进行原值比较和版本号比较,当比较成功之后会修改值并修改版本号。
  可重入锁也叫递归锁,指的是同一个线程,如果外面的函数拥有此锁之后,内层的函数也可以继续获取该锁。在 Java 语言中 ReentrantLock 和 synchronized 都是可重入锁。

public class LockExample {
    public static void main(String[] args) {
        reentrantA(); // 可重入锁
    }
    /**
     * 可重入锁 A 方法
     */
    private synchronized static void reentrantA() {
        System.out.println(Thread.currentThread().getName() + ":执行 reentrantA");
        reentrantB();
    }
    /**
     * 可重入锁 B 方法
     */
    private synchronized static void reentrantB() {
        System.out.println(Thread.currentThread().getName() + ":执行 reentrantB");
    }
}

  以上代码的执行结果如下:

复制 main:执行 reentrantA
     main:执行 reentrantB

  从结果可以看出 reentrantA 方法和 reentrantB 方法的执行线程都是“main” ,我们调用了 reentrantA 方法,它的方法中嵌套了 reentrantB,如果 synchronized 是不可重入的话,那么线程会被一直堵塞。
  可重入锁的实现原理,是在锁内部存储了一个线程标识,用于判断当前的锁属于哪个线程,并且锁的内部维护了一个计数器,当锁空闲时此计数器的值为 0,当被线程占用和重入时分别加 1,当锁被释放时计数器减 1,直到减到 0 时表示此锁为空闲状态。
  共享锁和独占锁
  只能被单线程持有的锁叫独占锁,可以被多线程持有的锁叫共享锁。
  独占锁指的是在任何时候最多只能有一个线程持有该锁,比如 synchronized 就是独占锁,而 ReadWriteLock 读写锁允许同一时间内有多个线程进行读操作,它就属于共享锁。
  独占锁可以理解为悲观锁,当每次访问资源时都要加上互斥锁,而共享锁可以理解为乐观锁,它放宽了加锁的条件,允许多线程同时访问该资源。
  说到锁首先想到的必然是synchronized,下面主要看下它是如何实现的?
  在 Java 中每个对象都隐式包含一个 monitor(监视器)对象,加锁的过程其实就是竞争 monitor 的过程,当线程进入字节码 monitorenter 指令之后,线程将持有 monitor 对象,执行 monitorexit 时释放 monitor 对象,当其他线程没有拿到 monitor 对象时,则需要阻塞等待获取该对象。
  synchronized关键字的运用主要包括三方面:
    锁代码块(锁对象可指定,可为this、XXX.class、全局变量)
    锁普通方法(锁对象是this,即该类实例本身)
    锁静态方法(锁对象是该类,即XXX.class)
首先我们看第一种情况,锁代码块,代码如下

public class SyncDemo{
 private int a = 0; 
public void add(){
 synchronized(this)
{
 System.out.println("a values " + ++a); 
} } }

通过反编译工具得到如下结果:
在这里插入图片描述
  由反编译结果可以看出:synchronized代码块主要是靠monitorenter和monitorexit这两个原语来实现同步的。当线程进入monitorenter获得执行代码的权利时,其他线程就不能执行里面的代码,直到锁Owner线程执行monitorexit释放锁后,其他线程才可以竞争获取锁。
这里主要涉及到的两个方法如下:
  (1)、monitorenter
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
  上述第2点就涉及到了可重入锁,意思就是说当一个线程已经获取一个锁时,它可以再获取无数次,从代码的角度上将就是有无数个相同的synchronized语句块嵌套在一起。在进入时,monitor的进入数+1;退出时就-1,直到为0的时候才可以被其他线程竞争获取。
  (2)、monitorexit
  执行monitorexit的线程必须是objectref所对应的monitor的所有者。
  指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

public class SyncDemo{ 
private int a = 0; 
public synchronized void add(){
 System.out.println(
 "a values " + ++a);
  }
}

  从反编译结果来看,这里没有monitorenter和monitorexit,但是常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。这种方式与语句块没什么本质区别,都是通过竞争monitor的方式实现的。只不过这种方式是隐式的实现方法。
 &ems这里,我们将以上两种方法进行一下说明:
  首先是代码块,当程序运行到monitorenter时,竞争monitor,成功后继续运行后续代码,直到monitorexit才释放monitor;而ACC_SYNCHRONIZED则是通过标志位来提示线程去竞争monitor。也就是说,monitorenter和ACC_SYNCHRONIZED只是起标志作用,并无实质操作。
  常量池中用ACC_STATIC标志了这是一个静态方法,然后用ACC_SYNCHRONIZED标志位提醒线程去竞争monitor。由于静态方法是属于类级别的方法(即不用创建对象就可以被调用),所以这是一个类级别(XXX.class)的锁,即竞争某个类的monitor。
  锁的竞争过程
  上面说明了如何提醒线程去争夺锁,所以接下来我们看下线程是怎样竞争锁的。其实总的来说,JVM中是通过队列来控制线程去竞争锁的。锁的竞争过程看下图:
在这里插入图片描述
  (1)、多个线程请求锁,首先进入Contention List,它可以接纳所有请求线程,而且是一个后进先出(LIFO)的虚拟队列,通过结点Node和next指针构造。
  (2)(3)、ContentionList会被线程并发访问,EntryList为了降低线程对ContentionList队尾的争用而构造出来。当Owner释放锁时,会从ContentionList中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head结点)为Ready Thread,也就是说某个时刻最多只有一个线程正在竞争锁。
  (4)、Owner并不是直接把锁交给OnDeck线程,而是将竞争锁的权利交给OnDeck(将锁释放了),然后让OnDeck自己去竞争。竞争成功后,OnDeck线程就变成Owner;否则继续留在EntryList的队头。
  (5)(6)、当线程调用wait方法被阻塞时,进入WaitSet;当其他线程调用notifyAll()(notify())方法后,阻塞队列的(某个)线程就会进入EntryList中。
  处于ContetionList、EntryList、WaitSet的线程均处于阻塞状态。而线程被阻塞涉及到用户态与内核态的切换(Liunx),系统切换严重影响锁的性能。解决这个问题的办法就是自旋。自旋就是线程不断进行内部循环,即for循环什么也不做,防止线程wait()阻塞,在自旋过程中不断尝试获取锁,如果自旋期间,Owner刚好释放锁,此时自旋线程就可以去竞争锁。如果自旋了一段时间还没获取到锁,那没办法,只能调用wait()阻塞了。
  为什么自旋了一段时间后又调用wait()方法呢?因为自旋是要消耗CPU的,而且还有线程上下文切换,因为CPU还可以调度线程,只不过执行的是空的for循环罢了。
   所以,synchronized是什么时候进行自旋的?答案是在进入ContetionList之前,因为它自旋一定时间后还没获取锁,最后它只好在ContetionList中阻塞等待了。
锁升级过程
   锁状态一种有四种,从级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁,锁状态只能升级,不能降级。锁升级其实就是从偏向锁到轻量级锁再到重量级锁升级的过程,这是 JDK 1.6 提供的优化功能,也称之为锁膨胀。
在这里插入图片描述
   偏向锁是指在无竞争的情况下设置的一种锁状态。偏向锁的意思是它会偏向于第一个获取它的线程,当锁对象第一次被获取到之后,会在此对象头中设置标示为“01”,表示偏向锁的模式,并且在对象头中记录此线程的 ID,这种情况下,如果是持有偏向锁的线程每次在进入的话,不再进行任何同步操作,如 Locking、Unlocking 等,直到另一个线程尝试获取此锁的时候,偏向锁模式才会结束,偏向锁可以提高带有同步但无竞争的程序性能。但如果在多数锁总会被不同的线程访问时,偏向锁模式就比较多余了,此时可以通过 -XX:-UseBiasedLocking 来禁用偏向锁以提高性能。
   轻量锁是相对于重量锁而言的,在 JDK 1.6 之前,synchronized 是通过操作系统的互斥量(mutex lock)来实现的,这种实现方式需要在用户态和核心态之间做转换,有很大的性能消耗,这种传统实现锁的方式被称之为重量锁
   而轻量锁是通过比较并交换(CAS,Compare and Swap)来实现的,它对比的是线程和对象的 Mark Word(对象头中的一个区域),如果更新成功则表示当前线程成功拥有此锁;如果失败,虚拟机会先检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,则说明当前线程已经拥有此锁,否则,则说明此锁已经被其他线程占用了。当两个以上的线程争抢此锁时,轻量级锁就膨胀为重量级锁,这就是锁升级的过程,也是 JDK 1.6 锁优化的内容。
  什么是死锁
  死锁是指两个线程同时占用两个资源,又在彼此等待对方释放锁资源。上一段代码如下:

public class LockExample {
	public static void main(String[] args) {
		deadlock();
	}
	public static  void deadlock(){
       Object lock1 = new Object();
       Object lock2 = new Object();
       new Thread(()->{
    	   synchronized (lock1) {
			System.out.println("获取lock1成功!");
			try {
				TimeUnit.SECONDS.sleep(2);
			} catch (Exception e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
    	   synchronized (lock2) {
			System.out.println(Thread.currentThread().getName());
		}
       }).start(); ;
       new Thread(()->{
    	   synchronized (lock2) {
			System.out.println("获取lock2成功!");
			try {
				TimeUnit.SECONDS.sleep(2);
			} catch (Exception e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
    	   synchronized (lock1) {
			System.out.println(Thread.currentThread().getName());
		}
       }).start(); ;
	}
}

  可以看出当我们使用线程一拥有锁 lock1 的同时试图获取 lock2,而线程二在拥有 lock2 的同时试图获取 lock1,这样就会造成彼此都在等待对方释放资源,于是就形成了死锁。

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值