一、线程安全

       在前面的Java多线程基础中我们就讲到了引入多线程带来的可能不仅仅是好处,还带来了一些问题,其中比较重要的问题之一就是线程安全。由于多线程同时访问可变的共享资源而导致程序出现不可预料的错误结果,则表示这段代码时线程不安全的。反之,线程安全则是指:当一段代码在多线程并发执行时不管线程调度的顺序如何这段代码的执行结果总是正确的,则表示这段代码时线程安全的。同理当一个类在多线程环境下访问时,不管线程如何调度,且在主调代码中不需要任何的额外同步则表示这个类都能表现出正确的行为,则称这个类是线程安全的类。

线程不安全的三种解决办法:

由于线程不安全时由于多线程并发访问可变共享资源导致的,所以线程不安全的解决之道则是在并发、可变和共享这三个关键词中寻找。

1、可变:将共享对象设置为不可变类型,所有线程都不能对该对象进行更改,显然这个对象是线程安全的。但是很多时候我们要求线程是能对共享资源进行写入的,所以此方法的局限性很大。

2、共享:在多线程环境下给每个线程提供一套资源,线程之间互不干扰。这其实就是线程封闭,既然对象都封闭在单个线程中了,那么自然是线程安全的。(在Java中线程封闭通常是使用ThreadLocal实现,此类我会单独写一篇博客进行解析)但是线程封闭的法子也有很大的局限性,绝大部分的业务场景都是无法给每个线程提供一套资源的,如秒杀场景,每个线程都必须去同一个仓库中取数据。

3、并发:由于不安全是线程并发导致的,那么在访问变量时使用同步策略也是解决线程不安全的办法之一。在大多数情况下使用同步都与锁离不开关系,在Java中锁的使用主要分为两种synchronized关键字和lock接口。

二、synchronized关键字

synchronized关键字是java中最简单实用的加锁方法,他可以作用于同步代码块和方法上。在java中为每一个对象都提供了一个内置锁也叫监视器锁,synchronized关键字就是作用于这个内置锁上,在进入同步块时自动获取锁,在离开时(无论正常或异常)自动释放锁。

1、作用于同步代码块:synchronized作用于同步代码块时锁住的是括号内的对象的内置锁

Object lock = new Object();
		synchronized (lock) {
			// doSomething
		}

2、作用于实例方法:在java中经常会看到一个方法前头加上synchronized(最典型的就是hashTable了),如果这个方法是实例方法,那么synchronized作用的是当前实例对象的内置锁,相当于synchronized(this),这意味着一个类的synchronized方法在被访问时,此类的其他synchronized方法是不允许其他线程访问的。

public synchronized void test() {
		//doSomething
	}

3、作用于静态方法:当一个方法同时被static和synchronized修饰时,那synchronized作用的不再是当前实例了,而是当前类的class对象锁,这意味着一个类的静态同步方法在被访问时,他所有的静态同步方法无法被其他线程访问,值得注意的是他的非静态同步方法仍可以被访问到,因为非静态同步方法使用的是当前实例的锁而不是当前类的class对象锁。

public static synchronized void test() {
		//doSomething
	}

synchronized虽然使用起来比较简单,但是却有许多局限性和性能问题: 

1、锁粒度过大:显然使用synchronized作用于方法上使得整个类都被上锁了而导致其他不相干的同步方法也无法被访问到,是一种性能十分低下的做法。

2、在等待锁阻塞时无法响应中断:当一个线程调用wait、join或sleep而导致阻塞时使用interrupt方法是可以让其立即抛出一个异常来响应中断的,但是当程序在等待锁或者等待IO而导致时,他是无法响应interrupt的中断的。

3、锁类型单一:synchronized是典型的悲观锁,并不使用与许多场景。有很多的场景下都是读多写少的,如秒杀、更新黑名单等。synchronized使用起来不够灵活,无法在多种不同的场景中提供很好的性能。虽然在jdk1.5之后对synchronized进行了一次比较大的更新,使其性能提高了不少,但在jdk1.5中同时也提供了另一种灵活的锁:Lock。

synchronized的底层实现原理我会单独写成一篇,今天主要讲JUC包下面的Lock接口实现的锁。


三、Lock接口

Lock接口是在jdk1.5中新引入的同步类,他提供了比synchronized更加灵活的加锁方式,lock接口的主要方法如下:

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

1、lock和unlock方法:

显然这两个方法是获取锁和释放锁的方法。在使用synchronized关键字时,进入和退出同步代码时都会自动帮你加锁和释放锁,但是lock不同,你必须显示的使用lock和unlock方法来获取和释放锁,并且一旦你调用了lock却忘记unlock时,会导致死锁的发生,而没调用lock却调用unlock时会导致你的程序出现异常,所以lock方法的典型使用规范如下:

Lock l = ...;
		  l.lock();
		  try {
		    // access the resource protected by this lock
		  } finally {
		    l.unlock();
		  }

2、lockInterruptibly()方法:

此方法也是获取锁,当锁可获取到时,立即加锁,当锁无法获取到时会使当前线程阻塞,但是可以响应中断,一旦调用interrupt方法会使当前线程抛出一个InterruptedException。synchronized的一大缺陷是在因等待锁而导致阻塞时无法响应中断,而lock接口的lockInterruptibly方法可以解决这一问题。

3、tryLock()和tryLock(long time, TimeUnit unit)

顾名思义这两个方法是用来尝试获取锁的,tryLock尝试一次而tryLock(long time, TimeUnit unit)会在给定时间范围内不断的尝试。显然尝试获取锁会出现两种结果:1、获取到了锁tryLock返回true,这是最好的情况。2、没拿到锁,此时tryLock方法会返回false而不是阻塞当前线程。当tryLock获取到锁执行完毕后必须释放,而没获取到则不能调用释放锁的方法,所以tryLock的典型使用规范如下:

if (lock.tryLock()) {
			try {
				// manipulate protected state
			} finally {
				lock.unlock();
			}
		} else {
			// perform alternative actions
		}

四、实现Lock接口的类

1、ReentrantLock

ReentrantLock顾名思义就是可重如锁,可重入的意思是同一个线程可以对某个对象反复的加锁,Java中断大多数锁都是可重入的如Synchronized关键字实现的锁也是可重入的。可重入锁的典型使用场景是:当一个线程在运行时会多次对某个对象进行加锁时,如果这个锁时不可重入的时会出现该线程第一次加锁导致其第二次无法再对该对象加锁,即该线程自己阻塞了自己,此时就会发生死锁现象。注意,一个线程对可重入锁加锁多少次就得解锁多少次否则会发生死锁。

ReentrantLock的构造方法有两个:

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

默认的构造方法为创建一个非公平锁,带参构造方法会根据参数创建公平或非公平锁。

公平锁和非公平锁的概念

公平锁指线程获得锁的顺序会严格的根据线程请求锁的顺序来决定,先来后到,先请求锁的线程一定会先获得锁,不管线程优先级如何。显然这种不顾线程优先级获得锁的方式是有缺陷的,我们往往希望比较重要和紧急的线程优先于不重要和执行时间过长的线程先获得锁,让紧急的线程先执行。但是公平锁的这种缺陷恰恰也是其存在的优点:由于非紧急线程要给紧急线程让锁,如果你的系统中频繁的出现紧急线程会导致非紧急线程永远也拿不到锁无法执行下去,此时便发生了线程饥饿,即线程原则上可以执行但优先级较低导致一直拖着不能执行,饥饿与死锁最大的区别在于原则上能不能够执行。公平锁让所有线程获得锁的几率相等,所以不会出现线程饥饿现象。

非公平锁:非公平锁与公平锁恰好相反,他会根据线程的紧急程度、执行时间和等待时长来决定线程获取锁的顺序。这种做法显然在大多数时候更符合我们的要求。合理的使用公平锁和非公平锁的带来性能上的区别是非常大的,以下例子演示了公平锁与非公平锁的区别:

public class Test{

	public static void main(String[] args) {
		Lock lock = new ReentrantLock(true);
		TestThread thread1 = new TestThread(lock);
		thread1.setName("线程1");
		thread1.setPriority(Thread.MAX_PRIORITY);
		TestThread thread2 = new TestThread(lock);
		thread2.setName("线程2");
		thread2.setPriority(Thread.MIN_PRIORITY);
		thread2.start();
		thread1.start();
	}

}

class TestThread extends Thread {
	private Lock lock;

	public TestThread(Lock lock) {
		this.lock = lock;
	}

	@Override
	public void run() {
		int i = 0;
		while (i < 10) {
			lock.lock();
			try {
				i++;
				System.out.println(this.getName() + "获取到锁");
				TestThread.sleep(500);
			} catch (Exception e) {
				e.printStackTrace();
			} finally {
				lock.unlock();
			}
		}

	}
}

此段代码输出如下,线程1和线程2会交替的获得锁:

线程1获取到锁
线程2获取到锁
线程1获取到锁
线程2获取到锁
线程1获取到锁
线程2获取到锁
线程1获取到锁
线程2获取到锁
线程1获取到锁
...

如果将ReentrantLock的参数设置为false,使用非公平锁,输出如下:

线程1获取到锁
线程1获取到锁
线程1获取到锁
线程1获取到锁
线程1获取到锁
线程1获取到锁
线程1获取到锁
线程1获取到锁
线程1获取到锁
线程1获取到锁
线程2获取到锁
线程2获取到锁
线程2获取到锁
线程2获取到锁
线程2获取到锁

线程一总是先于线程二获取锁,优先执行。

2、ReadLock&WriteLock

严格上来讲ReentrantLock算是唯一实现lock接口的类了,其他实现lock接口的类只有ReentrantReadWriteLock类中的两个内部类:ReadLock&WriteLock。顾名思义ReentrantReadWriteLock是一个可重入的读写锁,他继承自ReentrantLock类并且实现了读写锁接口ReadWriteLock。读写锁接口很简单,代码如下:

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

读写锁的两个方法返回读锁和写锁,读锁允许多个线程同时读取,读锁对其他读的线程是公共锁,他只与写锁互斥。而写锁则是绝对的互斥锁,在写锁被一个线程占有时其他所有的线程无论读写都得等待。

读写锁很多时候更符合业务场景,绝大部分的业务都是读多写少,如秒杀场景(每个线程都要读取库存,但不是所有的线程都有机会去修改库存),读写锁带来性能上的提升是非常明显的,以下程序演示了10个读线程和写线程的情况下读写锁和普通锁的差异:

public class ReadWriteLockTest extends Thread{
	private Lock Lock;
	private CountDownLatch countDownLatch;

	public ReadWriteLockTest(String name , Lock lock,CountDownLatch countDownLatch) {
		this.setName(name);
		this.Lock = lock;
		this.countDownLatch = countDownLatch;
	}

	@Override
	public void run() {
		Lock.lock();
		try {
			this.sleep(500); // 模拟读或写操作
			System.out.println(this.getName()+"执行结束");
		} catch (InterruptedException e) {
			e.printStackTrace();
		} finally {
			Lock.unlock();
			countDownLatch.countDown();
		}
	}

	public static void main(String[] args) throws InterruptedException {
	//	Lock lock = new ReentrantLock(); 普通锁
		ReentrantReadWriteLock lock = new ReentrantReadWriteLock();  // 读写锁
		CountDownLatch countDownLatch = new CountDownLatch(20);
		long start = System.currentTimeMillis();
		for(int i=0;i<10;i++) {
			new ReadWriteLockTest("读线程"+i,lock.readLock(),countDownLatch).start();
		}
		for(int i=0;i<10;i++) {
			new ReadWriteLockTest("写线程"+i,lock.writeLock(),countDownLatch).start();
		}
		countDownLatch.await();
		long end = System.currentTimeMillis();
		System.out.println("耗时:" +  (end - start) );
	}

}

以上程序在使用普通锁时耗时10秒,而在使用读写锁时耗时5.5秒,可见合理的使用读写锁带来性能上的提升是十分巨大的。