1.简述
锁:把需要的代码块,资源或数据锁上,只允许一个线程去操作,保证了并发时共享数据的一致性。
2.公平锁&非公平锁
公平锁:多个线程按照申请锁的顺序去获得锁,所有线程都在队列里排队,这样就保证了队列中的第一个先得到锁。
非公平锁:多个线程不按照申请锁的顺序去获得锁,而是同时直接去尝试获取锁(插队),获取不到(插队失败),再进入队列等待(失败则乖乖排队),如果能获取到(插队成功),就直接获取到锁。
公平锁的优缺点:
- 优点:所有的线程都能得到资源,不会饿死在队列中。
- 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
非公平锁的优缺点:
- 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
- 缺点:可能导致队列中排队的线程一直获取不到锁或者长时间获取不到锁,活活饿死。
性能:
- 非公平锁性能优于公平锁:因为公平锁在获取锁时,永远是等待时间最长的线程获取到锁,这样当线程释放锁以后,如果还想继续再获取锁,它也得去同步队列尾部排队,这样就会频繁的发生线程的上下文切换,当线程越多,对CPU的损耗就会越严重。非公平锁性能虽然优于公平锁,但是会存在导致线程饥饿的情况。在最坏的情况下,可能存在某个线程一直获取不到锁。不过相比性能而言,饥饿问题可以暂时忽略,这可能就是ReentrantLock默认创建非公平锁的原因之一了。
以买票为例子实现公平锁与非公平锁demo如下:
public class Demo{ //公平锁 private static Lock fairLock = new ReentrantLock(true); //非公平锁 private static Lock nonFairLock = new ReentrantLock(); private static int num = 100; public static void main(String[] args) throws Exception { new Thread(new Runnable(){ public void run() { testFairLock(); } }, "A窗口").start(); new Thread(new Runnable(){ public void run() { testFairLock(); } }, "B窗口").start(); } /**公平锁测试方法 */ public static void testFairLock(){ while (true) { try { fairLock.lock(); if (num > 0) { Thread.sleep(100); --num; System.out.println(Thread.currentThread().getName() + "占用1个座位,还剩余 " + num + "个座位"); } else { System.out.println(Thread.currentThread().getName() + ":不好意思,票卖完了!"); break; } } catch (InterruptedException e) { e.printStackTrace(); } finally { fairLock.unlock(); } } } /**非公平锁测试方法 */ public static void testNonFairLock(){ while (true) { try { nonFairLock.lock(); if (num > 0) { Thread.sleep(100); --num; System.out.println(Thread.currentThread().getName() + "占用1个座位,还剩余 " + num + "个座位"); } else { System.out.println(Thread.currentThread().getName() + ":不好意思,票卖完了!"); break; } } catch (InterruptedException e) { e.printStackTrace(); } finally { nonFairLock.unlock(); } } } }
通过输出可以看出公平锁是有序的排队执行,而非公平则不是。
对比公平锁与非公平锁的性能demo如下:
public class Demo{ //公平锁 private static Lock fairLock = new ReentrantLock(true); //非公平锁 private static Lock nonFairLock = new ReentrantLock(); //公平锁计数器 private static int fairCount = 0; //非公平锁计数器 private static int nonFairCount = 0; public static void main(String[] args) throws Exception { System.out.println("公平锁耗时:" + testFairLock(10)); System.out.println("非公平锁耗时:" + testNonFairLock(10)); System.out.println("公平锁累加结果:" + fairCount); System.out.println("非公平锁累加结果:" + nonFairCount); } /**公平锁测试方法 */ public static long testFairLock(int threadNum) throws InterruptedException{ long startTime = System.currentTimeMillis(); final CountDownLatch countDownLatch = new CountDownLatch(threadNum); // 创建threadNum个线程,让其以公平锁的方式,对fairCount进行自增操作 for (int i = 0; i < threadNum; i++) { new Thread(new Runnable(){ public void run() { for (int j = 0; j < 10000; j++) { fairLock.lock(); fairCount++; fairLock.unlock(); } countDownLatch.countDown(); } }).start(); } // 让所有线程执行完 countDownLatch.await(); long endTime = System.currentTimeMillis(); return endTime - startTime; } /**非公平锁测试方法 */ public static long testNonFairLock(int threadNum) throws InterruptedException{ long startTime = System.currentTimeMillis(); final CountDownLatch countDownLatch = new CountDownLatch(threadNum); // 创建threadNum个线程,让其以公平锁的方式,对fairCount进行自增操作 for (int i = 0; i < threadNum; i++) { new Thread(new Runnable(){ public void run() { for (int j = 0; j < 10000; j++) { nonFairLock.lock(); nonFairCount++; nonFairLock.unlock(); } countDownLatch.countDown(); } }).start(); } // 让所有线程执行完 countDownLatch.await(); long endTime = System.currentTimeMillis(); return endTime - startTime; } }
从上面的测试结果可以发现,非公平锁的耗时远远小于公平锁的耗时,这说明非公平锁在并发情况下,性能更好,吞吐量更大。当线程数越多时,差异越明显。
3.可重入锁&不可重入锁
可重入锁:也叫递归锁,指的是同一线程外层函数获得锁之后,内层递归函数仍然有获取该锁的代码,但不受影响。在Java中有ReentrantLock和synchronized。
不可重入锁:若当前线程执行中已经获取了锁,如果再次获取该锁时,就会获取不到被阻塞。在Java中有NonReentrantLock。
可重入锁诞生的目的就是防止死锁,导致同一个线程不可重入上锁代码段,目的就是让同一个线程可以重新进入上锁代码段。
可重入锁synchronized示例:
public class Demo{ public static void main(String[] args) throws Exception { new Thread(new Runnable() { public void run() { synchronized (this) { System.out.println("第1次获取锁,这个锁是:" + this); int index = 1; while (true) { synchronized (this) { System.out.println("第" + (++index) + "次获取锁,这个锁是:"+ this); } if (index == 10) { break; } } } } }).start(); } }
可重入锁reentrantLock示例:
public class Demo{ public static void main(String[] args) throws Exception { final Lock lock = new ReentrantLock(); new Thread(new Runnable() { @Override public void run() { try { lock.lock(); System.out.println("第1次获取锁,这个锁是:" + lock); int index = 1; while (true) { try { lock.lock(); System.out.println("第" + (++index) + "次获取锁,这个锁是:"+ lock); try { Thread.sleep(new Random().nextInt(200)); } catch (InterruptedException e) { e.printStackTrace(); } if (index == 10) break; } finally { lock.unlock(); } } } finally { lock.unlock(); } } }).start(); } }
注意点:ReentrantLock和synchronized不一样,ReentrantLock需要手动释放锁,所以使用 ReentrantLock的时候一定要手动释放锁,并且加锁次数和释放次数要一样,最好在 finally中进行锁释放
自定义可重入所示例:
public class Demo{ public static void main(String[] args) throws Exception { final Lock lock = new Lock(); new Thread(new Runnable() { @Override public void run() { try { lock.lock(); System.out.println("第1次获取锁,这个锁是:" + lock); int index = 1; while (true) { try { lock.lock(); System.out.println("第" + (++index) + "次获取锁,这个锁是:"+ lock); try { Thread.sleep(new Random().nextInt(200)); } catch (InterruptedException e) { e.printStackTrace(); } if (index == 10) break; } catch (Exception e) { System.out.println("第" + index + "次获取锁出现异常:"+ e.getMessage()); } finally { lock.unlock(); } } } catch (Exception e) { System.out.println("第1次获取锁出现异常:"+ e.getMessage()); } finally { lock.unlock(); } } }).start(); } } class Lock{ boolean isLocked = false; Thread lockedBy = null; int lockedCount = 0; public synchronized void lock()throws InterruptedException{ Thread thread = Thread.currentThread(); while(isLocked && lockedBy != thread){ wait(); } isLocked = true; lockedCount++; lockedBy = thread; } public synchronized void unlock(){ if(Thread.currentThread() == this.lockedBy){ lockedCount--; if(lockedCount == 0){ isLocked = false; notify(); } } } }
所谓可重入,意味着线程可以进入它已经拥有的锁的同步代码块儿。
不可重入锁,是指同一个线程不可以重入上锁后的代码段。
不可重入锁示例:
public class Demo{ public static void main(String[] args) throws Exception { final NonReentrantLock lock = new NonReentrantLock(); new Thread(new Runnable() { @Override public void run() { try { lock.lock(); System.out.println("第1次获取锁,这个锁是:" + lock); int index = 1; while (true) { try { lock.lock(); System.out.println("第" + (++index) + "次获取锁,这个锁是:"+ lock); try { Thread.sleep(new Random().nextInt(200)); } catch (InterruptedException e) { e.printStackTrace(); } if (index == 10) break; } catch (Exception e) { System.out.println("第" + index + "次获取锁出现异常:" + e.getMessage()); } finally { lock.unlock(); } } } catch (Exception e) { System.out.println("第1次获取锁出现异常:" + e.getMessage()); } finally { lock.unlock(); } } }).start(); } } class NonReentrantLock { private boolean isLocked = false; public synchronized void lock() throws InterruptedException { while (isLocked) { wait(); } isLocked = true; //线程第一次进入后就会将器设置为true,第二次进入是就会由于where true进入死循环 } public synchronized void unlock() { isLocked = false;//将这个值设置为false目的是释放锁 notify();//结束阻塞 } }
第一次上锁后,由于没有释放锁,因此执行第一次lock后isLocked = true,这个时候又一次调用了lock(),由于上个线程将isLocked = true,再次进入的时候就进入死循环。从而导致线程无法执行下去。这种现象就造成了不可重入锁。
4.悲观锁&乐观锁
悲观锁:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。Java中Synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
乐观锁:乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。在Java中java.util.concurrent.atomic包下面的原子变量类就是基于CAS实现的乐观锁。
使用场景:
- 悲观锁:适用于读比较少的情况下(多写场景),如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行重试,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
- 乐观锁:适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。
悲观锁的demo如下:
public class Demo{ public static void main(String[] args) throws Exception { for (int i = 1; i <= 5; i++) { new Thread(new Runnable(){ public void run() { System.out.println(Thread.currentThread().getName() + ":等待"); visit(); } }, i+"号线程").start(); } } private static synchronized void visit() { System.out.println(Thread.currentThread().getName()+ ":操作数据 "); try { Thread.sleep((long) (Math.random() * 5000)); } catch (InterruptedException e) { e.printStackTrace(); } } }
从输出可以看到,每次有线程访问这个资源visit()方法时,在访问前先锁定了资源,导致其他线程只能等待,也就是说不能多个线程在访问它。
乐观锁可以使用版本号机制和CAS算法实现。
乐观锁:乐观锁不需要线程挂起等待,所以也叫非阻塞同步。
版本号机制:一般在一个数据表中加一个version字段,表示这个数据被更新的次数,当这个数据被修改一次,版本号就加1。
版本号机制条件:提交版本必须大于当前记录的版本。
版本号机制实现例子如下:
我现在银行账户有10元,现在有一个version字段,版本号为1。 现在我A操作取出2元,我先读入数据version=1,然后扣除。 与此同时,B操作也要取出1元,读入数据version=1,然后扣除。 这个时候,A操作完成,上传数据,版本号加1,version=2,这个版本大于当前的记录值1,所以更新操作完成。 这个时候,B操作也完成了,也要更新,他的版本号加1,version=2,然后更新的时候发现这个版本号和当前记录的版本号相同,不满足提交版本号必须大于当前记录的版本号的条件,不允许更新。 这个时候,B操作就要重新读入再重复之前的步骤。
通过这样的方式,我们就保证了B操作不会将A操作所更新的值覆盖,保证了数据的同步。
CAS算法:即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。
CAS算法涉及到三个操作数:
- 需要读写的内存值V。
- 进行比较的值A。
- 拟写入的新值B。
当且仅当V的值等于A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
使用AtomicBoolean原子类实现的一个无阻塞多线程争抢资源的模型,示例如下:
public class Demo{ private static AtomicBoolean flag = new AtomicBoolean(true); public static void main(String[] args) throws Exception { new Thread(new Runnable() { public void run() { test(); } }, "线程1").start(); new Thread(new Runnable() { public void run() { test(); } }, "线程2").start(); } private static void test() { System.out.println(Thread.currentThread().getName()+":flag="+flag.get()); if (flag.compareAndSet(true, false)){ System.out.println(Thread.currentThread().getName()+":flag="+flag.get()); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } flag.set(true); }else{ System.out.println(Thread.currentThread().getName()+"重试机制:flag="+flag.get()); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } test(); } } }
示例中compareAndSet(true, false)方法可以拆开成compare(true)方法和Set(false)方法理解,是compare(true)等于true后,就马上设置共享内存为false,这个时候,其它线程无论怎么走都无法走到只有得到共享内存为true时的程序隔离方法区。
但是这种得不到状态为true时使用递归算法是很耗cpu资源的,所以一般情况下,都会有线程sleep。
CAS的缺点:
- CPU开销较大:在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
- 不能保证代码块的原子性:CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证多个变量共同进行原子性的更新,就不得不使用Synchronized了。
5.独享锁&共享锁
独享锁和共享锁都是通过AQS队列来实现的,通过实现不同的方法,来实现独享或者共享。
独享锁:该锁一次只能被一个线程所持有,参考synchronized以及JUC包下的ReentrantLock。
共享锁:该锁可被多个线程所持有,参考JUC包下的ReentrantReadWriteLock。
6.互斥锁&读写锁
互斥锁:互斥锁与悲观锁、独占锁同义,表示某个资源只能被一个线程访问,其他线程不能访问,Java提供了两种互斥锁来解决在共享资源时存在的并发问题,一种方式是synchronized关键字,另一种方式是显式的使用Lock对象。
读写锁:读写锁是一种技术,通过ReentrantReadWriteLock类来实现。为了提高性能, Java提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的。
读写锁:
- 读锁: 允许多个线程获取读锁,同时访问同一个资源。
- 写锁: 只允许一个线程获取写锁,不允许同时访问同一个资源。
synchronized同步代码块的方式实现互斥锁:
public class Demo { //数量 private static int count = 100; public static void main(String[] args) { for (int i = 1; i <= 3; i++) { new Thread(new Runnable(){ public void run() { while(true){ if(count > 1) synchronizedTest(); else break; } } },i+"号线程").start(); } } private synchronized static void synchronizedTest(){ try { System.out.println(Thread.currentThread().getName()+"剩余数量为:" + count--); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } }
显式Lock锁方式实现互斥锁:
public class Demo { //数量 private static int count = 100; private static Lock lock = new ReentrantLock(); public static void main(String[] args) { for (int i = 1; i <= 3; i++) { new Thread(new Runnable(){ public void run() { while(true){ if(count > 1) synchronizedTest(); else break; } } },i+"号线程").start(); } } private static void synchronizedTest(){ lock.lock(); try { System.out.println(Thread.currentThread().getName()+"剩余数量为:" + count--); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } lock.unlock(); } }
读写锁实现操作缓存:
class ReadWriteCache { //充当cache static Map<String, Object> map = new HashMap<String, Object>(); //实例化读写锁对象 static ReadWriteLock rwLock = new ReentrantReadWriteLock(); //实例化读锁 static Lock read = rwLock.readLock(); //实例化写锁 static Lock write = rwLock.writeLock(); //获取缓存中值 public static final Object get(String key) { read.lock(); try { return map.get(key); } finally { read.unlock(); } } //写缓存中值,并返回对应value public static final Object set(String key, Object obj) { write.lock(); try { return map.put(key, obj); } finally { write.unlock(); } } //清空所有内容 public static final void clear() { write.lock(); try { map.clear(); } finally { write.unlock(); } } }
7.分段锁
分段锁:其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作(Java 8, JDK弃用了这个策略,重新使用了synchronized)。
基于散列的Map的实现分段锁:
class StripedMap { // 同步策略: buckets[n]由 locks[n%N_LOCKS] 来保护 private static final int N_LOCKS = 16;// 分段锁的个数 private final Node[] buckets; private final Object[] locks; /**结点 * @param <K> * @param <V> */ private static class Node<K, V> implements Map.Entry<K, V> { final K key;// key V value;// value Node<K, V> next;// 指向下一个结点的指针 int hash;// hash值 // 构造器,传入Entry的四个属性 Node(int h, K k, V v, Node<K, V> n) { value = v; next = n;// 该Entry的后继 key = k; hash = h; } public final K getKey() { return key; } public final V getValue() { return value; } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } } /**构造器: 初始化散列桶和分段锁数组 * @param numBuckets */ public StripedMap(int numBuckets) { buckets = new Node[numBuckets]; locks = new Object[N_LOCKS]; for (int i = 0; i < N_LOCKS; i++) { locks[i] = new Object(); } } /**返回散列之后在散列桶之中的定位 * @param key * @return */ private final int hash(Object key) { return Math.abs(key.hashCode() % N_LOCKS); } /**分段锁实现的get * @param key * @return */ public Object get(Object key) { int hash = hash(key);// 计算hash值 // 获取分段锁中的某一把锁 synchronized (locks[hash % N_LOCKS]) { for (Node m = buckets[hash]; m != null; m = m.next) { if (m.key.equals(key)) { return m.value; } } } return null; } /**清除整个map */ public void clear() { // 分段获取散列桶中每个桶地锁,然后清除对应的桶的锁 for (int i = 0; i < buckets.length; i++) { synchronized (locks[i % N_LOCKS]) { buckets[i] = null; } } } }
实现示例中使用了N_LOCKS个锁对象数组,并且每个锁保护容器的一个子集,对于大多数的方法只需要回去key值的hash散列之后对应的数据区域的一把锁就行了。但是对于某些方法却要获得全部的锁,比如clear()方法,但是获得全部的锁不必是同时获得,可以使分段获得。
8.自旋锁
自旋锁:是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
Java自旋锁:Jdk提供的java.util.concurrent.atomic包里面提供了一组原子类。基本上就是当前获取锁的线程,执行更新的方法,其他线程自旋等待,比如atomicInteger类中的getAndAdd方法内部实际上使用的就是Unsafe的方法。
利用CAS实现自旋锁:
public class Demo { static int count = 0; public static void main(String[] args) throws Exception { //线程池 ExecutorService exec = Executors.newFixedThreadPool(2); //任务集合 List<Callable<Integer>> tasks = new ArrayList<Callable<Integer>>(); //任务 Callable<Integer> task = null; final SpinLock spinLock = new SpinLock(); for (int i = 0; i < 2; i++) { task = new Callable<Integer>() { public Integer call() throws Exception { while(count < 50){ spinLock.lock(); System.out.println(Thread.currentThread().getName()+" 开始运行"); count++; Thread.sleep(500); System.out.println(Thread.currentThread().getName()+" 结束运行"); spinLock.unLock(); } return 1; } }; //这里提交的任务容器列表和返回的Future列表存在顺序对应的关系 tasks.add(task); } //执行线程任务 exec.invokeAll(tasks); //关闭线程池 exec.shutdown(); System.out.println(count); } } class SpinLock { /**持有锁的线程,null表示锁未被线程持有 */ private AtomicReference<Thread> cas = new AtomicReference<>(); public void lock() { Thread currentThread = Thread.currentThread(); while (!cas.compareAndSet(null, currentThread)) {//利用CAS //通过循环不断的自旋判断锁是否被其他线程持有 } System.out.println(Thread.currentThread().getName()+" 获取锁"); } public void unLock() { Thread cur = Thread.currentThread(); cas.compareAndSet(cur, null); System.out.println(Thread.currentThread().getName()+" 释放锁"); } }
实现示例中看出,自旋就是在循环判断条件是否满足,如果锁被占用很长时间的话,自旋的线程等待的时间也会变长,白白浪费掉处理器资源。因此在JDK中,自旋操作默认10次,我们可以通过参数“-XX:PreBlockSpin”来设置,当超过来此参数的值,则会使用传统的线程挂起方式来等待锁释放。
9.偏向锁&轻量级锁&重量级锁
锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁,只能升级,不能降级。
偏向锁:偏向锁就是在运行过程中,对象的锁偏向某个线程。即在开启偏向锁机制的情况下,某个线程获得锁,当该线程下次再想要获得锁时,不需要再获得锁(即忽略synchronized关键词),直接就可以执行同步代码,比较适合竞争较少的情况。
轻量级锁:轻量级锁不是用来替代传统的重量级锁的,而是在没有多线程竞争的情况下,使用轻量级锁能够减少性能消耗,但是当多个线程同时竞争锁时,轻量级锁会膨胀为重量级锁。
重量级锁:即当有其他线程占用锁时,当前线程会进入阻塞状态。
这些锁不等同于Java API中的ReentratLock这种锁,这些锁是概念上的,是JDK1.6中为了对synchronized同步关键字进行优化而产生的的锁机制。这些锁的启动和关闭策略可以通过设定JVM启动参数来设置,当然在一般情况下,使用JVM默认的策略就可以了。