本系列文章:
  多线程(一)多线程基础
  多线程(二)Java内存模型、同步关键字
  多线程(三)线程池
  多线程(四)显式锁、队列同步器
  多线程(五)可重入锁、读写锁
  多线程(六)线程间通信机制
  多线程(七)原子操作、阻塞队列
  多线程(八)并发容器
  多线程(九)并发工具
  多线程(十)多线程编程总结

一、ReentrantLock(可重入锁)

  重入锁可以替代synchronized。在JDK5的早期版本中,重入锁的性能远远优于synchronized,但从JDK6开始,JDK在关键字synchronized上做了大量的优化,使得两者的性能差距并不大。

  ReentrantLock(可重入锁),主要利用CAS+AQS队列来实现,支持公平锁和非公平锁。
  ReentrantLock使用示例:

	private Lock lock = new ReentrantLock();
	 
	public void test(){
	    lock.lock();
	    try{
	        doSomeThing();
	    }catch (Exception e){
	    }finally {
	        lock.unlock();
	    }
	}

  ReentrantLock的基本实现可以概括为:先通过CAS尝试获取锁。如果此时已经有线程占据了锁,那就加入AQS队列并且被挂起。当锁被释放之后,排在队列队首的线程会被唤醒,然后CAS再次尝试获取锁。在这个时候,如果:

  • 1、ReentrantLock对象是非公平锁
      如果同时还有另一个线程进来尝试获取,那么有可能会让这个线程抢先获取;
  • 2、ReentrantLock对象是公平锁
      如果同时还有另一个线程进来尝试获取,当它发现自己不是在队首的话,就会排到队尾,由队首的线程获取到锁。

1.1 ReentrantLock的特点(可重入/可响应中断/可实现公平锁/可设置超时时间)

1.1.1 可重入锁

  可重入锁是指同一个线程可以多次获取同一把锁。ReentrantLock和synchronized都是可重入锁。
  关于可重入性,示例:

	static ReentrantLock lock = new ReentrantLock();
	
	public static void main(String[] args) {
		method1();
	}
	
	public static void method1() {
		lock.lock();
		try {
			log.debug("execute method1");
			method2();
		} finally {
			lock.unlock();
		}
	}

	public static void method2() {
		lock.lock();
		try {
			log.debug("execute method2");
			method3();
		} finally {
			lock.unlock();
		}
	}
	
	public static void method3() {
		lock.lock();
		try {
			log.debug("execute method3");
		} finally {
			lock.unlock();
		}
	}
1.1.2 可中断锁

  可中断锁是指线程尝试获取锁的过程中,是否可以响应中断。synchronized是不可中断锁,而ReentrantLock则提供了中断功能。
  当使用synchronized实现锁时,阻塞在锁上的线程除非获得锁否则将一直等待下去,也就是说这种无限等待获取锁的行为无法被中断。而ReentrantLock给我们提供了一个可以响应中断的获取锁的方法lockInterruptibly(),该方法可以用来解决死锁问题。
  接下来看个例子:两个子线程,子线程在运行时会分别尝试获取两把锁。其中一个线程先获取锁1在获取锁2,另一个线程正好相反。如果没有外界中断,该程序将处于死锁状态永远无法停止。此时可以使其中一个线程中断,来结束线程间毫无意义的等待。被中断的线程将抛出异常,而另一个线程将能获取锁后正常结束。示例:

public class TestDemo {
    static Lock lock1 = new ReentrantLock();
    static Lock lock2 = new ReentrantLock();
    public static void main(String[] args) throws InterruptedException {
		//该线程先获取锁1,再获取锁2
        Thread thread = new Thread(new ThreadDemo(lock1, lock2));
        //该线程先获取锁2,再获取锁1
        Thread thread1 = new Thread(new ThreadDemo(lock2, lock1));
        thread.start();
        thread1.start();
        thread.interrupt();//是第一个线程中断
    }

    static class ThreadDemo implements Runnable {
        Lock firstLock;
        Lock secondLock;
        
        public ThreadDemo(Lock firstLock, Lock secondLock) {
            this.firstLock = firstLock;
            this.secondLock = secondLock;
        }
        
        @Override
        public void run() {
            try {
                firstLock.lockInterruptibly();
                TimeUnit.MILLISECONDS.sleep(10);//更好的触发死锁
                secondLock.lockInterruptibly();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                firstLock.unlock();
                secondLock.unlock();
                System.out.println(Thread.currentThread().getName()+"正常结束!");
            }
        }
    }
}

  结果:

1.1.3 公平锁与非公平锁

  公平锁是指多个线程同时尝试获取同一把锁时,锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO;而非公平锁则允许线程“插队”。synchronized是非公平锁,而ReentrantLock的默认实现是非公平锁,但是也可以设置为公平锁。公平锁会影响性能。
  ReentrantLock提供了两个构造方法:

	public ReentrantLock() {
	    sync = new NonfairSync();
	}
	 
	public ReentrantLock(boolean fair) {
	    sync = fair ? new FairSync() : new NonfairSync();
	}

  默认构造方法初始化为NonfairSync对象,即非公平锁,而带参数的构造器可以指定使用公平锁和非公平锁。
  公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。
  公平锁:加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得。
  非公平锁:加锁时不考虑排队等待问题,直接尝试获取锁,获取不到自动到队尾等待。

  1. 非公平锁性能比公平锁高5~10倍,因为公平锁需要在多核的情况下维护一个队列。
  2. Java中的synchronized是非公平锁,ReentrantLock默认的lock()方法采用的是非公平锁。
  • 公平策略与非公平策略
      简单来说,如果一个线程先申请锁,先获得锁,就表示使用了公平策略。如果某个线程后申请锁,却先获得了锁,就表示使用了非公平策略。
      一般来说,非公平调度策略的吞吐率较高。它的缺点是:从申请者个体的角度来看,这些申请者获得相应资源的独占权所需时间的偏差可能比较大,即有的线程很快就能申请到资源,而有的线程则要经历若干次暂停与唤醒才能成功申请到资源,极端情况下可能导致饥饿现象
      公平调度策略的吞吐率较低,这是其维护资源独占权的授予顺序的开销比较大(主要是线程的暂停与唤醒所导致的上下文切换)的结果。其优点是,从个体申请者的角度来看,这些申请者获得相应资源的独占权所需时间的偏差可能比较小,即每个资源申请者申请到资源所需的时间基本相同,并且不会导致饥饿现象。

  synchronized是非公平锁,ReentrantLock可以设置公平锁或非公平锁,默认是非公平锁。其构造函数:

    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

  当参数fair为true时,表示锁是公平的。公平锁要求系统维护一个有序队列,因此公平锁的实现成本比较高,性能比较低下。因此,默认情况下,锁是非公平的。如果没有特别要求,则不需要使用公平锁。

  公平锁与非公平锁的比较:

  • 1、公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序;而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象
  • 2、公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,而非公平锁会降低一定的上下文切换,降低性能开销。因此,ReentrantLock默认选择的是非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量。
1.1.4 可以进行超时设置(避免了无限等待)

  在ReetrantLock中,tryLock(long timeout, TimeUnit unit) 提供了超时获取锁的功能。它的语义是在指定的时间内如果获取到锁就返回true,获取不到则返回false。
  超时机制避免了线程无限期的等待锁释放
  tryLock方法可以不带参数直接运行。在这种情况下,当前线程会尝试获得锁,如果锁未被其他线程占用,则申请锁会成功,并立即返回true。如果锁被其他线程占用,则当前线程不会进行等待,而是立即返回false。这种模式不会引起线程等待,也不会产生死锁。
  用超时机制解决死锁的例子:

	public class TestDemo {
	    static Lock lock1 = new ReentrantLock();
	    static Lock lock2 = new ReentrantLock();
	    public static void main(String[] args) throws InterruptedException {
			//该线程先获取锁1,再获取锁2
	        Thread thread = new Thread(new ThreadDemo(lock1, lock2));
	        //该线程先获取锁2,再获取锁1
	        Thread thread1 = new Thread(new ThreadDemo(lock2, lock1));
	        thread.start();
	        thread1.start();
	    }
	
	    static class ThreadDemo implements Runnable {
	        Lock firstLock;
	        Lock secondLock;
	        public ThreadDemo(Lock firstLock, Lock secondLock) {
	            this.firstLock = firstLock;
	            this.secondLock = secondLock;
	        }
	        @Override
	        public void run() {
	            try {
	                while(!lock1.tryLock()){
	                    TimeUnit.MILLISECONDS.sleep(10);
	                }
	                while(!lock2.tryLock()){
	                    lock1.unlock();
	                    TimeUnit.MILLISECONDS.sleep(10);
	                }
	            } catch (InterruptedException e) {
	                e.printStackTrace();
	            } finally {
	                firstLock.unlock();
	                secondLock.unlock();
	                System.out.println(Thread.currentThread().getName()+"正常结束!");
	            }
	        }
	    }
	}

  结果:

Thread-0正常结束!
Thread-1正常结束!

  再看一个例子:

public class TimeLock implements  Runnable{
    public static ReentrantLock lock = new ReentrantLock();

    @Override
    public void run() {
        try{
            if (lock.tryLock(5, TimeUnit.SECONDS)) {
                Thread.sleep(6000);
            }else{
                System.out.println(Thread.currentThread().getName()+" get lock fail");
            }
        }catch(InterruptedException e){
            e.printStackTrace();
        }finally {
            // 查当前线程是否占用该锁
            if(lock.isHeldByCurrentThread()){
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        TimeLock tl = new TimeLock();
        Thread t1 = new Thread(tl);
        Thread t2 = new Thread(tl);
        t1.start();
        t2.start();
    }
}

  上述代码中,由于t1、t2分配到CPU执行时间的不确定性,所以代码会输出“Thread-1 get lock fail”或“Thread-0 get lock fail”。

1.2 相关问题

1.2.1 tryLock(可以进行超时设置)、lock(没获取锁就一直等待/响应中断不抛异常)和lockInterruptibly(响应中断时抛异常)的区别

  这3个方法都用来获取锁。

  1. tryLock能获得锁就返回true,不能就立即返回false,tryLock(long timeout,TimeUnit unit),可以增加时间限制,如果超过该时间段还没获得锁,返回 false。
  2. lock能获得锁就返回true,不能的话一直等待获得锁
  3. lock和lockInterruptibly,如果两个线程分别执行这两个方法,但此时中断这两个线程,lock不会抛出异常,而lockInterruptibly会抛出异常。触发InterruptedException异常之后,线程的中断标志会被清空,即置为false。
1.2.2 ReentrantLock是如何实现可重入性的*
  • 什么是可重入性
      一个线程持有锁时,当其他线程尝试获取该锁时,会被阻塞;而这个线程尝试获取自己持有锁时,如果成功说明该锁是可重入的,反之则不可重入。
  • ReentrantLock如何实现可重入性
      ReentrantLock使用内部类Sync来管理锁,所以真正的获取锁是由Sync的实现类控制的。Sync有两个实现,分别为NonfairSync(非公公平锁)和FairSync(公平锁)。Sync通过继承AQS实现,在AQS中维护了一个private volatile int state来计算重入次数(可以理解为计数器),避免频繁的持有释放操作带来的线程问题。
      当state的值不为0时,表示锁已经被一个线程占用了,这时会做一个判断current==getExclusiveOwnerThread(),这个方法返回的是当前持有锁的线程,这个判断是看当前持有锁的线程是不是自己,如果是自己,那么将state的值+1,表示重入返回即可。
1.2.3 跟Synchronized相比,可重入锁ReentrantLock其实现原理有什么不同

  其实,锁的实现原理基本是为了达到一个目的:让所有的线程都能看到某种标记。
  Synchronized通过在对象头中设置标记实现了这一目的,是一种JVM原生的锁实现方式,而ReentrantLock以及所有的基于Lock接口的实现类,都是通过用一个volitile修饰的int型变量,并保证每个线程都能拥有对该it的可见性和原子修改,其本质是基于所谓的AQS框架。

二、ReentrantReadWriteLock

  有这样的场景:对共享资源有读和写的操作,且写操作没有读操作那么频繁。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源;但是如果一个线程想去写这些共享资源,就不应该允许其他线程对该资源进行读和写的操作了。
  针对这种场景,可以用读写锁ReentrantReadWriteLock,它表示两个锁,一个是读操作相关的锁,称为共享锁;一个是写相关的锁,称为排他锁。

  读写锁允许同一时刻被多个读线程访问,但是在写线程访问时,所有的读线程和其他的写线程都会被阻塞(即读锁是共享锁,写锁是排他锁)。即:

读-读不互斥:读读之间不阻塞。
读-写互斥:读阻塞写,写液会阻塞读。
写-写互斥:写写阻塞。

2.1 ReentrantReadWriteLock的特点(支持公平锁和非公平锁/可重入锁/锁可以降)

  读写锁有以下三个重要的特性:

  • 1、支持公平锁和非公平锁
      支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
  • 2、可重入锁
      读锁和写锁都支持线程重进入。
  • 3、锁可以降级
      遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。
      一个线程要想同时持有写锁和读锁,必须先获取写锁再获取读锁;写锁可以“降级”为读锁;读锁不能“升级”为写锁

2.2 读写锁的互斥性测试

  • 1、基础代码
	public class ReadWriteLockTest {
	    private ReentrantReadWriteLock rw1 = new ReentrantReadWriteLock();
	
	    //获取写锁
	    public  void getW(Thread thread) {
	    	try {
	    		rw1.writeLock().lock();
	    		long start = System.currentTimeMillis();
	    		while (System.currentTimeMillis() - start <= 10){
	    			System.out.println(thread.getName() + "正在写操作");
	    		}
	    		System.out.println(thread.getName() + "写操作完成");
		    } catch (Exception e) {
		        e.printStackTrace();
		    } finally {
		        rw1.writeLock().unlock();
		    }
	
	    }
	    
	    //获取读锁
	    public  void getR(Thread thread) {
		    try {
		        rw1.readLock().lock();
		        long start = System.currentTimeMillis();
		        while (System.currentTimeMillis() - start <= 10){
		            System.out.println(thread.getName() + "正在读操作");
		        }
		        System.out.println(thread.getName() + "读操作完成");
		    } catch (Exception e) {
		        e.printStackTrace();
		    } finally {
		        rw1.readLock().unlock();
		    }
	    }
	}  
  • 2、并发读
    public static void main(String[] args) {
        final ReadWriteLockTest test = new ReadWriteLockTest();

        new Thread(){
            @Override
            public void run() {
                test.getR(Thread.currentThread());
            }
        }.start();

        new Thread(){
            @Override
            public void run() {
                test.getR(Thread.currentThread());
            }
        }.start();
    }

  结果:

Thread-1正在读操作
Thread-0正在读操作
Thread-1读操作完成
Thread-0读操作完成

  可以看到读线程间是不用排队的。

  • 3、并发写
    public static void main(String[] args) {
        final ReadWriteLockTest test = new ReadWriteLockTest();

        new Thread(){
            @Override
            public void run() {
                test.getW(Thread.currentThread());
            }
        }.start();

        new Thread(){
            @Override
            public void run() {
                test.getW(Thread.currentThread());
            }
        }.start();
    }

  结果:

  可以看出写线程获取锁是互斥的。

  • 4、并发读写
    public static void main(String[] args) {
        final ReadWriteLockTest test = new ReadWriteLockTest();

        new Thread(){
            @Override
            public void run() {
                test.getR(Thread.currentThread());
            }
        }.start();

        new Thread(){
            @Override
            public void run() {
                test.getW(Thread.currentThread());
            }
        }.start();
    }

  结果:

  可以看出读写线程获取锁也是互斥的。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐