锁是什么?

首先从字面意思来说,锁的概念就是“阻止”和“封闭”一些不能让别人访问的东西。我们可以参考百度百科(锁)的说法。其实不管其作用是什么但锁的核心目标就是安全,安全来说涉及的东西就多了。
锁

我们可以说是为了保护一样东西,阻挡不怀好意的坏东西;当然也可以说是指定了如同权限一样的东西,所谓的安全可能涉及范围真的很广,但在程序设计中能想到跟安全有关的东西无非就那么几个大方向:系统稳定、数据异常、数据安全、访问限制等。


hhh,扯皮扯的挺多的了,好了我们直接步入正题:

什么是开发眼中的锁?

     实现数据安全的权限手段

     所谓的数据安全并非是犹如加密一样的锁概念,在这之前其实我们应该引入多线程的概念(详细见《关于Java多线程的一些常问问题的刨析》

     “掌握Java中锁是Java多线程编程中绕不开的知识,只有知道理解Java各种锁才能在编码过程中灵活运用,写出更高效的多线程程序。”

     这是对于多线程应用的普遍认知,从这些关系我们就可以看出,所谓开发中的“锁”并不是那么现实化,我们通常认为的锁是锁住一个物品,但在开发中它更像是一扇门一个房间,封住了一个只允许单人访问的”物品“,只有拿到钥匙的人才可以进入房间。

     当然在这里所谓的钥匙也会只有一把(这是通常情况下)。

     以上都是转义化的解释,其实锁的出现无非就是解决线程冲突的问题,更术语点可以称做为线程安全。我们都知道多线程可以在同一时间进行不同的操作,这是我们认为最符合预期的样子,但是有没有想过,当多个线程在同一时刻对同一件事进行操作时会有什么后果?

     显而易见的,这是会出现数据安全的;现在有三个人(A、B、C),三个人一同去称重(在程序中不管多么一同都是有先后关系的),A先站了上去,发现自己50kg;这时候他拿出本子准备记下来,反头来看称的时候B已经站上去了,于是A记下了A的体重为100kg,完了,这下A就裂开了,这数据真实嘛准确吗,显然之前的数据是已经丢失的了。

     以上就是线程安全中最典型的问题,数据安全。从不同的非常规场景可以预见更多的线程安全问题,于是便有了线程锁的出现。

因此非要说是开发眼中的“锁”的话,不如说其实它叫做线程锁


怎么在java中实现锁?


以上说了很多都是一些对锁概念的说明,我们更多的是明白怎么去使用它,怎么在实际开发中去运用它,那么怎么在java中实现锁呢?


synchronized关键字


     实现锁之前我们得先认识一个java关键字(关键字是什么?), synchronized(同步关键字),作为java中实现锁的重要部分,同步关键字是唯二实现java锁机制的东西。

     synchronized关键字可以应用于方法或语句块,并为一次只应由一个线程执行的关键代码段提供保护(百度百科的解释)。代码演示如下:


作用于方法:

public synchronized void method()
{
   // todo
}

作用于代码块:

public void method()
{
   synchronized(this) {
      // todo
   }
}

两者只是作用范围不一样,修饰代码块是大括号括起来的范围,而修饰方法范围是整个函数。


     以上我们可以视为,在synchronized关键字范围内的代码,在被访问操作时只允许一个线程进入(其他线程访问时则需要等待获得访问资格的线程完成操作后释放锁才行),而在这个其他线程进行等待的过程我们可以称之为线程阻塞(等待),由此这就是java中锁的实现和应用。


Lock对象


     我们都知道所谓java关键字其实就是一个保留字,类似的如void、null等,我们上面也说到过,实现锁机制中同步关键字是唯二的实现锁方式,所以接下来就是另外一种实现锁的方式,通过对象来实现。(这里插一句其实哪怕是同步关键字其实本质上也是一个对象只不过我们所看到的样子并不像,所以java中万物皆对象是真的


万物皆对象


     java中提供了一个锁对象Lock,Lock的实现其底层其实也是对synchronized关键字底层的实现封装,其实还有更多区别,如果有想了解的话可以在评论区留言,到时候可以专门写一篇,也可以参考博客《Lock接口实现类与方法》-二:synchronized的缺陷

     与synchronized不同的是,Lock锁是纯Java实现的,与底层的JVM无关。在java.util.concurrent.locks包中有很多Lock的实现类,常用的有ReentrantLock、ReadWriteLock(实现类ReentrantReadWriteLock),其实现都依赖java.util.concurrent.AbstractQueuedSynchronizer类,这里就不过多阐述。


Lock的实现类


     synchronized是Java原生的互斥同步锁,使用方便,对于synchronized修饰的方法或同步块,无需再显式释放锁。而ReentrantLock做为API层面的互斥锁,需要显式地去加锁解锁。采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁(你可以理解为锁的无限套娃或者说锁未释放的问题)的发生。


class X {
    private final ReentrantLock lock = new ReentrantLock();
    // ...
 
    public void m() {
      lock.lock();  // 加锁
      try {
        // ... 函数主题
      } finally {
        lock.unlock() //解锁
      }
    }
}

作为对象去实现锁机制,它可以更加自由以及深度的定制化加锁,只不过麻烦程度肯定相比synchronized是要高的,不同的开发场景肯定是用到不用的加锁方案的,这个就需要长期的开发经验了。


锁的运用与分类


我们已经了解到锁的实现方法了,而聪明的开发人员们在实际开发中应对不同的场景所运用的锁方案进行汇总总结,形成了一些锁的分类运用,而深刻理解这一层才是合格的“锁开发”人员。首先需要知道几个名词:

  • 公平锁/非公平锁
  • 可重入锁
  • 独享锁/共享锁
  • 互斥锁/读写锁
  • 乐观锁/悲观锁
  • 分段锁
  • 偏向锁/轻量级锁/重量级锁
  • 自旋锁

详细介绍


公平锁/非公平锁


     所谓公平锁,顾名思义,意指锁的获取策略相对公平,当多个线程在获取同一个锁时,必须按照锁的申请时间来依次获得锁,排排队,不能插队;非公平锁则不同,当锁被释放时,等待中的线程均有机会获得锁。synchronized是非公平锁,ReentrantLock默认也是非公平的,但是可以通过带boolean参数的构造方法指定使用公平锁,但非公平锁的性能一般要优于公平锁。

     非公平锁最宏观的表现就是结果不可预测化,你永远不知道是哪一个线程被优先选择了,公平锁通常的表现可以视为从代码的先后编译来。


接下来我们从源码角度来看看ReentrantLock的实现原理,它是如何保证可重入性,又是如何实现公平锁的。

1、无参构造器(默认为非公平锁)

public ReentrantLock() {
     sync = new NonfairSync();//默认是非公平的
}

sync是ReentrantLock内部实现的一个同步组件,它是Reentrantlock的一个静态内部类,继承于AQS。


2、带布尔值的构造器(是否公平)

public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();//fair为true,公平锁;反之,非公平锁
}

此处可以指定是否采用公平锁,FailSync和NonFailSync亦为Reentrantlock的静态内部类,都继承于Sync


还有更多实现方式这里就不过多展现了。


可重入锁


所谓的可重入性,就是可以支持一个线程对锁的重复获取,原生的synchronized就具有可重入性,一个用synchronized修饰的递归方法,因此又名递归锁,当线程在执行期间,它是可以反复获取到锁的,而不会出现自己把自己锁死的情况。ReentrantLock也是如此,在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。

// 此处用代码演示了可重入锁的代码层意思

synchronized void setA() throws Exception{   

    Thread.sleep(1000);

    setA();   // 因为获取了setA()的锁(即获取了方法外层的锁),此时递归调用setA()将再次获取一个新锁,如果不自动获取的话方法A的递归将不会执行           

}

独享锁/共享锁


独享锁是指该锁一次只能被一个线程所持有。
共享锁是指该锁可被多个线程所持有。


     在一般的开发当中我们通常对读写的操作进行不同的加锁方案,最好的一个例子就是Lock的一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。

读锁的共享可以保证在其他线程操作写入操作时,不影响正常业务的读取操作,这样可以使并发读的操作变得非常高效,写入操作的独享锁又能保证并发写入操作数据的安全性。

独享锁与共享锁是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

**抽象队列同步器(AbstractQueuedSynchronizer,简称AQS)**是用来构建锁或者其他同步组件的基础框架,想详细了解的话可以去查询相关的博客文章,这里就不做过多说明了。


互斥锁/读写锁


其实上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。


互斥锁

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


读写锁

读写锁既是互斥锁,又是共享锁,读写锁其实就是对读写操作进行加锁的统称,一般情况下我们都是采取的读操作共享,写操作独享这样的,综合上述所说其实读写锁非常适合多读少写的情况。。


乐观锁/悲观锁


     乐观锁和悲观锁并不是指具体类型的一种锁,而是一种看待并发同步操作的处理看法。


乐观锁

     乐观锁就是在任何并发同步操作的情况中,都乐观的觉得不会出现数据冲突问题,觉得数据出现冲突的操作是不会进行实际值改变的,通过不停的反复尝试来进行值修改,通常认为不加锁也可以让并发操作不会有什么问题。


悲观锁

     悲观锁其实就和乐观锁相反,它会觉得任何并发操作中如不过不进行加锁操作就一定会出现数据安全问题,悲观的认为不加锁的并发操作一定是不安全的,它会认为并发操作一定会影响实际值变化。

     从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。

悲观锁在Java中的使用,就是利用各种锁。
乐观锁在Java中的使用,是无锁编程,常常采用业务逻辑来进行仿锁的操作,进行多次的尝试进行值插入修改。


分段锁


     分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

     我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

     当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

     但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。


偏向锁/轻量级锁/重量级锁


     这三种锁是指锁的状态,并且是针对Synchronized。在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

偏向锁

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

偏向锁的适用场景

     始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用

     在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用;

轻量级锁

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

重量级锁

     是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

     由上我们可以看到,三种状态的锁变化,会导致获取锁的成本越来越高,而在线程设计中我们必须尽量避免出现重量级锁的情况。


自旋锁


     在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

     自旋锁原理其实非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗

     自旋锁尽可能的减少线程的阻塞,适用于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗

     但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cpu的线程又不能获取到cpu,造成cpu的浪费。

     自旋锁的话,可以理解为一个反复询问有没有开锁的线程。


总结


     锁的概念在多线程的重要程度中占据了非常的大的比例,如果对锁的概念不熟悉可以说你接下来的多线程开发会出现各种各样的问题。

     该篇博客其实也只是我个人对于锁概念的浅薄认识,如果要更加深入的理解的话,建议购买相关的专业书籍进行阅读(emmm平时不是特别喜欢看书所以也没什么推荐的,)。

     如果有什么看不懂或者不理解的地方可以在下方登录账号后进行评论,我看到了都会进行回复的( ̄▽ ̄)”