Sychronized和Lock对比剖析

一 开篇

客观的讲,sychronized lock都属于悲观锁(共享的资源每次只能给一个线程使用,其他线程处于阻塞状态,用完之后才释放资源给其他线程使用)。都能够实现数据的同步访问,sychronized是java中的一个关键字,属于java内置的语言特性,在Java1.5之后,在java.util.concurrent.locks包下提供了另外一种方式来实现同步访问,那就是Lock。

那么疑问来了,既然sychronized可以实现数据的同步访问,那么为什么还要增加lock呢?接下来我们来讲解下增加lock的原因。

二 sychronized 和 Lock的区别

1.来源:sychronized是java的关键字,是java的内置特性。Lock不是java关键字,是java.util.concurrent.locks包下的一个类,通过这个类可以实现数据的同步访问。

2.释放锁:sychronized方法(代码块)执行完毕之后或者执行期间出现异常,系统会自动释放锁,不会导致死锁现象。Lock必须要用户去手动释放锁,如果没有主动释放,那么很可能出现死锁现象。

3.响应中断:Lock可以让等待的线程响应中断,而sychronized不行,会一直等待下去

4.获取锁状态:Lock可以知道线程是否成功获取锁,sychronized不行

5.高并发效率:Lock可以提高多线程作业效率

在性能上来说,如果资源竞争不是很激烈的情况下,两者性能相差不多,当有大量的线程竞争资源时,而Lock的性能远远高于sychronized,所以根据情况选择合适的锁是关键

三 为什么引入Lock类?

1.为了能够灵活设置线程等待时间和响应中断,提高程序执行效率

从上面了解到,sychronized释放锁的情况有两种:1.sychronized方法(代码块)执行完毕。2.线程执行过程中发生异常,JVM自动释放锁。那么如果中途遇到等待IO或者sleep等原因被阻塞了,其他线程只能干等,这种情况下,程序的执行效率将会非常低下。但Lock可以设置线程等待的时间和相应中断!!!,其他线程可放弃等待,去做其他事情。

2.有效识别线程读操作,提高资源可利用范围

sychronized加锁时,多个线程都只是进行读操作,所以当一个线程在进行读操作时,其他线程只能等待无法进行读操作。

但Lock可以使得多个线程都是读操作时,线程之间不会发生冲突。

3.sychronized无法知道线程有没有获取到锁,但Lock可以知道

tryLock()和tryLock(long time,Unit unit)方法是有返回值的,获取到锁返回true,否则返回false

四 java.util.concurrent.locks包下的类和接口介绍

java.util.concurrent.locks包下,我们需要关注的也就:两个接口Lock接口,ReadWriteLock接口),两个实现类ReentrantLock类,ReentrantReadWriteLock),其中,ReentrantLock类是Lock接口的唯一实现,ReentrantReadWriteLock类是ReadWriteLock接口的实现。

1.Lock接口

通过查看源码可知,Lock是一个接口,方法如下:

public interface Lock {
    //获取锁,无返回值
    void lock();
    //获取锁,获取不到中断(发音:因特rub特-波类)
    void lockInterruptibly() throws InterruptedException;
    //获取锁,成功返回true,否则false
    boolean tryLock();
    //尝试一段时间内获取锁,成功返回true,否则false
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    //手动释放锁
    void unlock();
    Condition newCondition();
}

Lock接口中的方法:lock(),lockInterruptibly(),tryLock(),tryLock(long time,TimeUnit unit)都是用来获取锁的。unLock()是用来释放锁的,newCondition()方法暂不做赘述。

1. lock()方法

lock()方法是比较常用的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。

前面讲到,如果使用Lock,必须要手动释放锁,即便发生异常,也不会释放锁。因此,使用Lock时,建议必须要用try{}catch(){} 捕获一下,然后将释放锁的操作放在finally()代码块中,防止死锁发生,使用方式如下。

Lock lock=...;
lock.lock();
try{
    //事务处理
}catch(Exception e){
   
}finally{
    //释放锁
    lock.unlock();
}

2. tryLock()方法

tryLock()方法是有返回值的,尝试获取锁,如果成功返回true,否则返回false,如果拿不到锁可以尝试去做其他事情,不用一直等待。

tryLock(long time, TimeUnit unit)方法可tryLock()类似,区别在于获取不到锁时会等待一段时间,在时间期限内拿到锁返回ture,否则返回false,使用方式如下。

Lock lock=...;
if(lock.tryLock()){
    try{
        //处理事务
    }catch(Exception e){
    
    }finally{
        //释放锁
        lock.unLock();
    }
}else{
    //获取不到锁,做其他操作
}

3.lockInterruptibly()方法

lockInterruptibly()方法比较特殊,能够响应中断,即中断等待状态,也就是或如果两个线程A,B分别获取锁,A获取到锁是,B一直处于等待状态,可以通过B.interrupt()方法能够中断线程B的等待过程。

由于lockInterruptibly()的声明中抛出了异常,所以lock.lockInterruptibly()必须放在try{}catch{}中或者抛出InterruptedException 异常,使用代码如下。

public class LockTest{
    private Lock lock=new ReentrantLock();

    public void method(){
        try{
            lock.lockInterruptibly();
            //事务处理
            System.out.println(Thread.currentThread().getName()+"获取到锁!!!");
            System.out.println(Thread.currentThread().getName()+"先睡它个30s!!!");
            Thread.sleep(30000);
        }catch(Exception e){

        }finally{
            //释放锁
            lock.unlock();
        }
    }

    @Test
    public void testInterruptibly() throws InterruptedException{
        Runnable t1=new Runnable(){
            @Override
            public void run(){
                method();
            }
        };
        Runnable t2=new Runnable(){
            @Override
            public void  run(){
                method();
            }
        };
        String tName=Thread.currentThread().getName();
        System.out.println(tName+"-启动t1!");
        Thread t11=new Thread(t1);
        t11.start();
        System.out.println(tName+"-我等个5秒,再启动t2");
        Thread.sleep(5000);
        System.out.println(tName+"-启动t2");
        Thread t22=new Thread(t2);
        t22.start();
        System.out.println(tName+"-t2获取不到锁,t1睡觉了,没释放,我等个5秒!");
        Thread.sleep(5000);
        System.out.println(tName+"-等了5秒了,不等了,把t1中断了!");
        t22.interrupt();

        Thread.sleep(Long.MAX_VALUE);
    }
}

注意:已经获取到锁的线程是无法用interrupt()方法中断的,interrupt()方法只能中断被阻塞的线程。因此当通过lockInterruptibly()方法获取某个锁时,如果不能获取到,只有进行等待的情况下,是可以响应中断的。

而用synchronized修饰的话,当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等待下去。

2. ReentrantLock实现类

ReentrantLock,(发音:瑞恩垂特Lock)意思为“可重入锁”,ReentrantLock具有公平和非公平两种模式,也各有优缺点:
公平锁是严格的以FIFO的方式进行锁的竞争,但是非公平锁是无序的锁竞争,刚释放锁的线程很大程度上能比较快的获取到锁,队列中的线程只能等待,所以非公平锁可能会有“饥饿”的问题。但是重复的锁获取能减小线程之间的切换,而公平锁则是严格的线程切换,这样对操作系统的影响是比较大的,所以非公平锁的吞吐量是大于公平锁的,这也是为什么JDK将非公平锁作为默认的实现。

ReentrantLock是唯一实现了Lock接口的类,并且ReentrantLock提供了更多的方法.

lock(),tryLock(),tryLock(long time,TimeUnit unit),lockInterruptibly()方法使用方式即我们上面介绍的那样,唯一注意的地方是声明Lock的时候

private Lock lock = new ReentrantLock();    //注意这个地方

提示:如果希望线程能够正确的获取锁,要注意:同一个实例对象,多个线程才有竞争关系,加锁才有意义,不同的实例对象要有竞争关系,必须在对象类级别加锁。

3. ReadWriteLock接口

ReadWriteLock也是一个接口,它只定义了两个抽象方法

public interface ReadWriteLock {
    /**
     * Returns the lock used for reading.
     *
     * @return the lock used for reading.
     */
    Lock readLock();
 
    /**
     * Returns the lock used for writing.
     *
     * @return the lock used for writing.
     */
    Lock writeLock();
}

readLock()用来获取读锁,wirteLock()用来获取写锁。也就是说将资源的读写操作分开,分成2个锁来分配给线程,从而使得多个线程可以同时进行读操作。下面的ReentrantReadWriteLock实现了ReadWriteLock接口。

4.ReentrantReadWriteLock实现类

ReentrantReadWriteLock实现了接口ReadWriteLock,ReentrantReadWriteLock里面提供了丰富的方法,最主要的两个方法为:readLock()和wirteLock()用来获取读锁和写锁。

我们知道,sychronized加锁是无法识别线程是读操作还是写操作的,一律是一个线程获取到锁后,其他线程等待状态,但ReentrantReadWriteLock的读锁和写锁实现了读写分离,读操作可以允许多个线程同时进行,使用方式如下。

private ReadWriteLock rwl=new ReentrantReadWriteLock();
    public void m1(Thread thread){
        rwl.readLock().lock();//读锁
        try {
            long start = System.currentTimeMillis();

            while(System.currentTimeMillis() - start <= 1) {
                System.out.println(thread.getName()+"正在进行读操作");
            }
            System.out.println(thread.getName()+"读操作完毕");
        } finally {
            rwl.readLock().unlock();
        }
    }

不同的线程可同时申请读锁,大大提高程序执行效率。

但要注意的是,如果有一个线程已经占用了读锁,其他线程想要申请写锁,那么申请写锁的线程必须要等待读锁释放。

如果一个线程已经占用了写锁,其他线程想申请读锁或者写锁,则申请线程必须要等待写锁释放。

五 锁概念介绍

1.可重入锁

定义:如果锁具备可重入性,那么我们称之为可重入锁。像sychronized和ReentrantLock都是可重入锁。可重入性在我看来实际是表明了锁的分配机制:锁是基于线程分配的,而不是基于方法调用的分配

举个简单的例子,有两个sychronized修饰的方法,method1和method2,其中method1调用了method2,那么如果一个线程获取了method1的锁,那么该不需要再去申请method2的锁,可直接执行方法method2,代码如下:

public class MyClass{
    public sychronized void method1(){
        method2();
    }

    public sychronized void method2(){
    
    }
}

上述method1和method2都被sychronized修饰了,如果sychronized不具备可重入性,假如某一时刻,线程A获取到了method1的锁,那么还需申请该对象锁,问题是A已经占用了该对象锁,这样A就会一直等待永远获取不到的锁。

然而,sychronized和ReentrantLock都具有可重入性,所以不会发生上述这种情况。

2.可中断锁

可中断锁:顾名思义,可以响应中断的锁。sychronized是不可中断锁,Lock是可以中断的。

如果某个线程执行锁代码期间,其他线程不想等待了,想先处理其他事情,我们可以让它中断自己或者在别的线程中中断它,这种就是可中断锁。就是前面讲述的Lock中的lockInterruptiblly()方法。通过threadX.interrupt()可实现线程的中断。

3.公平锁和非公平锁

遵循FIFO的原理,即尽量以请求锁的顺序来获取锁。比如同是有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该所,这种就是公平锁。

sychronized是非公平锁,无法保证获取锁的顺序和请求锁的顺序一致,所以就可能出现某个线程永远获取不到锁的情况。但重复的获取锁,可以减少线程间的切换消耗的资源,所以非公平锁的吞吐量会比公平锁的要大。

ReentrantLock和ReentrantReadWriteLock默认都是非公平锁,但是可以设置成公平锁,看下这两个类的源代码:

ReentrantLock内部设置了2个静态内部类,一个NonfairSync,一个FairSync,分别用来实现公平锁和非公平锁。

看下ReentrantLock的构造函数,如下图,根据构造函数可以看到,默认无参情况下是非公平锁。设置锁的公平性可以

ReentrantLock lock=new ReentrantLock(true);

传true参数代表公平锁,不传或者传false,代表非公平锁。 

 

此外,ReentrantLock类中还定义了很多其他的方法,比如:

  1.   isFair()        //判断锁是否是公平锁
  2.   isLocked()    //判断锁是否被任何线程获取了
  3.   isHeldByCurrentThread()   //判断锁是否被当前线程获取了
  4.   hasQueuedThreads()   //判断是否有线程在等待该锁

 在ReentrantReadWriteLock中也有类似的方法,同样也可以设置为公平锁和非公平锁。不过要记住,ReentrantReadWriteLock并未实现Lock接口,它实现的是ReadWriteLock接口。

 

4.读写锁

  读写锁其实就是将对资源的访问分成了2个锁,一个读锁和一个写锁。

  正因为有了读写锁,才使得多个线程之间的读操作不会发生冲突。

  ReadWriteLock就是读写锁,它是一个接口,ReentrantReadWriteLock实现了这个接口。

  可以通过readLock().lock()获取读锁,通过writeLock().lock()获取写锁。

六 拓展 volatile

在java线程并发处理中,有一个关键字volatile的使用目前存在很大的混淆,以为使用这个关键字,在进行多线程并发处理的时候就可以万事大吉。

Java语言是支持多线程的,为了解决线程并发的问题,在语言内部引入了 同步块 和 volatile 关键字机制。

用volatile修饰的变量,线程在每次使用变量的时候,都会读取变量修改后的最的值。volatile很容易被误用,用来进行原子性操作。

举个例子,实际操作,我们实现一个计数器,每次线程启动的时候,会调用计数器inc方法,对计数器进行加一,同时启动1000个线程,去进行i++计算,看看实际结果,运行结果:Counter.count=995

实际运算结果每次可能都不一样,本机的结果为:运行结果:Counter.count=995,可以看出,在多线程的环境下,Counter.count并没有期望结果是1000

很多人以为,这个是多线程并发问题,只需要在变量count之前加上volatile就可以避免这个问题,那我们在修改代码看看,看看结果是不是符合我们的期望

运行结果:Counter.count=992

运行结果还是没有我们期望的1000,下面我们分析一下原因

  • 在 java 垃圾回收整理一文中,描述了jvm运行时刻内存的分配。其中有一个内存区域是jvm虚拟机栈,每一个线程运行时都有一个线程栈,
  • 线程栈保存了线程运行时候变量值信息。当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存
  • 变量的具体值load到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,
  • 在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。这样在堆中的对象的值就产生变化了。
  • read and load 从主存复制变量到当前工作内存use and assign  执行代码,改变共享变量值 store and write 用工作内存数据刷新主存相关内容
  • 其中use and assign 可以多次出现
  • 但是这一些操作并不是原子性,也就是 在read load之后,如果主内存count变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,所以计算出来的结果会和预期不一样

对于volatile修饰的变量,jvm虚拟机只是保证从主内存加载到线程工作内存的值是最新的

例如假如线程1,线程2 在进行read,load 操作中,发现主内存中count的值都是5,那么都会加载这个最新的值

在线程1堆count进行修改之后,会write到主内存中,主内存中的count变量就会变为6

线程2由于已经进行read,load操作,在进行运算之后,也会更新主内存count的变量值为6

导致两个线程及时用volatile关键字修改之后,还是会存在并发的情况。

参考链接:https://www.cnblogs.com/handsomeye/p/5999362.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章