1. 锁纵览

1、Lock接口
2、锁的分类
3、乐观锁和悲观锁
4、可重入锁和非可重入锁,已ReentrantLock为例(重点)
5、公平锁和非公平锁
6、共享锁和排他锁:以ReentrantReadWriteLock读写锁为例(重点)
7、自旋锁和阻塞锁
8、可中断锁:顾名思义,就是可以中断的锁
9、锁优化

2. Lock接口

2.1 Lock接口简介

锁是一种工具,用于控制对共享资源的访问。Lock和synchronized,这两个是最常见的锁,他们都可以达到线程安全的目的,但是在使用上和功能上又有较大的不同。Lock并不是用来替代synchronized的,而是当使用synchronized不适合或者不足以满足要求的时候,来提供高级功能。

Lock接口最常见的实现类ReentrantLock,通常情况下,Lock只允许一次一个线程来访问这个共享资源。不过有的时候,一些特殊的实现也可以允许并发访问,比如ReadWriteLock里面的ReadLock

为什么synchronized不够用?为什么需要lock?

1)效率低:锁的释放情况少、试图获得锁不能设定超时时间、不能中断一个正在试图获得锁的过程。
2)不够灵活(读写锁更灵活):加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能是不够的。
3)无法知道是否成功获取到锁
为什么需要Lock?

2.2 Lock主要方法介绍

在Lock中声明了四个方法类获取锁
lock()、tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptibly(),那么这四个方法有何区别呢?

1)lock

lock()就是最普通的获取锁,如果锁已经被其他线程获得,则进行等待,Lock不会像synchronized一样在异常时自动释放锁,因此最佳的实践是,在finally中释放锁,以保证发生异常时锁一定被释放。lock()方法不能被中断,这会带来很大的隐患:一旦陷入死锁,lock()就会陷入永久等待。

2)tryLock()

tryLock()用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,则返回true,否则返回false,代表获取锁失败,相比于lock,这样的方法显然功能更加强大了,我们可以根据是否能过获取到锁来决定后续程序的行为。该方法会立即返回,即便在拿不到锁时不会一直在那等。

3)tryLock(long time, TimeUnit unit)

超时就放弃

4)lockInterruptibly

相当于tryLock(long time, TimeUnit unit)把超时时间设置为无限,在等待锁的过程中,线程可以被中断

5)unlock

  1. 解锁方法。

2.3 可见性保证

保证可见性要遵循happens-before,Lock的加解锁和synchronized有同样的内存语义,也就是说,下一个线程加锁后可以看到前一个线程解锁前发生的所有操作。一定要拿到锁才能保证可见性。

image.png
Lock可见性
image.png

3.锁的分类

这些分类,是从各种不同角度出发去看的。这些分类并不是互斥的,也就是多个类型可以并存:有可能一个锁同时属于两种类型。比如ReentrantLock即是互斥锁也是可重入锁,好比一个人可以同时是男人,又是军人。

image.png

3.1 乐观锁和悲观锁

乐观锁即非互斥同步锁,悲观锁即互斥同步锁。

1) 悲观锁

为什么会诞生非互斥同步锁
互斥同步锁的劣势,阻塞和唤醒带来的性能劣势,用户态核心态切换,线程上下文切换,带来一系列损耗,永久阻塞,如果持有锁的线程被永久阻塞,比如遇到了无限循环、死锁等活跃性问题,那么等待该进程释放锁的那几个悲催的线程,永远的得不到执行。
优先级反转,比如说低优先级的线程拿到锁了,但是阻塞住了,优先级高的线程无法去执行任务,导致优先级错乱。
从是否锁住资源的角度分类,如果我不锁住这个资源,别人就会来争抢,就会造成数据结果错误,所以每次悲观锁为了确保结果的正确性,会在每次获取并修改数据时,把数据锁住,让别人无法访问该数据,这样就可以确保数据内容万无一失。Java中悲观锁的实现就是Synchronized和Lock相关类。

2) 乐观锁

认为自己在处理操作的时候不会有其他线程来干扰,所以并不会锁住被操作对象,在更新的时候,去对比在我修改的期间数据有没有被其他人改变过:如果没被改变过,就说明真的是只有我自己在操作,那我就正常去修改数据。
如果数据和我一开始拿到的不一样了,说明其他人在这段时间修改过数据,那我就不能继续刚才的更新数据过程了,我会选择放弃、报错、重试等策略。乐观锁的实现一般都是利用CAS算法来实现的。

CAS在一个原子操作内,提交。

image.png

image.png

悲观锁:synchronized和lock接口
乐观锁的典型例子就是原子类、并发容器等。

3) 典型例子

Git:Git就是乐观锁的典型例子,当我们往远端仓库push的时候,git会检查远端仓库的版本不是领先于我们现在的版本,如果远程仓库的版本号和本地的不一样,就表示有其他人修改了远端代码,我们的这次提交就失败;如果远端和本地版本号一致,我们就可以顺利的提交版本到远端仓库。
Git不适合用悲观锁,否则公司倒闭。

数据库
select for update就是悲观锁,用version控制数据库就是乐观锁。添加一个字段lock——version,先查询这个更新语句的version:select * from table,然后update set num = 2,version = version +1 where version = 1 and id = 5;如果version被更新了等于2,不一样就会更新出错,这就是乐观锁的原理。

悲观锁的原始开销要高于乐观锁,但是特点是一劳永逸,临界区持锁时间就算越来越差,也不会对互斥锁的开销造成影响,相反,虽然乐观锁一开始的开销比悲观锁小,但是如果自旋时间很长或者不停重试,那么消耗的资源也会越来越多

4) 使用场景

悲观锁:适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗,典型情况:

  • 临界区有IO操作
  • 临界区代码复杂或者循环量大
  • 临界区竞争非常激烈

乐观锁:适合并发写入少,大部分是读取的场景,不加锁能让读取性能大幅提高。可以说几乎不会发生并发。

3.2 可重入锁与不可重入锁

1) ReentrantLock用法

电影院抢座位场景
image.png

image.png

image.png
普通用法1:预定电影院座位,模拟四个线程去获取锁,之后去预定座位。

/**
 * 描述: 演示多线程预定电影院座位
 */
public class CinemaBookSeat {
    private static ReentrantLock lock = new ReentrantLock();
    private static void bookSeat() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "开始预定座位");
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName() + "完成预定座位");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        new Thread(() -> bookSeat()).start();
        new Thread(() -> bookSeat()).start();
        new Thread(() -> bookSeat()).start();
        new Thread(() -> bookSeat()).start();
    }
}

普通用法2:打印字符串,演示被打断。

/**
 * 描述: 演示ReentrantLock的基本用法,演示被打断
 */
public class LockDemo {
    public static void main(String[] args) {
        new LockDemo().init();
    }
    private void init() {
        final Outputer outputer = new Outputer();
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    try {
                        Thread.sleep(5);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    outputer.output("悟空");
                }
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                while(true) {
                    try {
                        Thread.sleep(5);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    outputer.output("二师兄");
                }
            }
        }).start();
    }
    static class Outputer {
        Lock lock = new ReentrantLock();

        // 字符串打印方法,一个个字符的打印
        public void output(String name) {
            int len = name.length();
            lock.lock();
            try {
                for (int i = 0; i < len; i++) {
                    System.out.print(name.charAt(i));
                }
                System.out.println("");
            } finally {
                lock.unlock();
            }
        }
    }
}

如果使用ReentrantLock就会出现错乱的情况
image.png

2) 可重入的性质

reentrant 可重新进入的。ReentrantLock的好处,避免死锁、提高封装性,比如synchronized,拿到了这把锁,由于不可重入,再去拿这把锁,就会陷入死锁

实例:通过lock.getHoldCount()方法获取当前线程获取锁的次数。

/**
 * 获取已经获取了几次锁
 */
public class GetHoldCount {
    private static ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {
        // 三次加锁三次解锁 getHoldCount线程拿到锁的次数
        System.out.println(lock.getHoldCount());
        lock.lock();
        System.out.println(lock.getHoldCount());
        lock.lock();
        System.out.println(lock.getHoldCount());
        lock.lock();

        System.out.println(lock.getHoldCount());
        lock.unlock();
        System.out.println(lock.getHoldCount());
        lock.unlock();
        System.out.println(lock.getHoldCount());
        lock.unlock();
    }
}

运行结果:
image.png

实例:递归处理资源

/**
 * 描述:递归获取资源
 */
public class RecursionDemo {
    private static ReentrantLock lock = new ReentrantLock();

    private static void accessResource() {
        lock.lock();
        try {
            // 处理五次才能处理完毕
            System.out.println("已经对资源进行了处理");
            if (lock.getHoldCount() < 5) {
                System.out.println(Thread.currentThread().getName() + lock.getHoldCount());
                accessResource();
                System.out.println(Thread.currentThread().getName() + lock.getHoldCount());
            }
        }finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        accessResource();
    }
}

运行结果:
image.png

3) 源码分析

源码对比:可重入锁ReentrantLock以及非可重入锁ThreadPoolExecutor的Worker类。

image.png

4) 其他方法

isHeldByCurrentThread可以看出锁是否被当前线程持有
getQueueLength可以返回当前正在等待这把锁的队列有多长,一般这两个方法是开发和调试时候使用,上线后用到的不多。

3.3 公平锁和非公平锁

公平指的事按照线程请求的顺序,来分配锁;非公平指的是,不完全按照请求顺序,在一定情况下,可以插队。注意非公平也同样不提倡插队行为,这里的非公平,指的是“在合适的时机”插队,而不是盲目插队。
为什么要有非公平锁。
看到这里,小伙伴肯定会生气了,联想到自己买票的时候被插队的情况,更是怒火中烧,凭什么默认策略非公平,我难道前面线程白白排队了吗!Java设计者是不是没有素质!
实际情况并不是这样的,Java设计者这样设计的目的是为了提高效率。来避免唤醒的空档期:举个例子,比如说当前锁被线程1持有,线程2等待中,线程1执行完毕后释放锁,但是唤醒线程2,需要一定的时间成本,在这个过程中,线程3来获取锁,并且几乎可以在线程2唤醒直接完成任务,CPU就先将锁给了线程3,让他先执行,而不是耽误线程2的执行,这里看出线程3显然“插队”了,但是效率是最高的。

1) 公平的情况(以ReentrantLock为例)

如果在创建ReentrantLock对象,参数填写为true,那么这就是个公平锁。假设线程1234是按顺序调用lock()的,后续等待的线程会到wait queue里,按照顺序依次执行。
image.png

在线程1执行unlock()释放锁之后,由于此时线程2的等待时间最久,所以线程2先得到执行,然后是线程3和线程4

image.png

2) 不公平的情况(以ReentrantLock为例)

如果线程1释放锁的时候,恰好线程5去执行lock(),由于ReentrantLock发现此时并没有线程持有lock这把锁(线程2还没来得及获取到,因为需要时间),线程5可以插队,直接拿到这把锁,这也是ReentrantLock默认的公平策略,也就是“不公平”。
image.png
实例:代码演示公平锁和非公平锁

/**
 * 描述: 演示公平锁与非公平锁
 */
public class FairLock {
    public static void main(String[] args) throws InterruptedException {
        PrintQueue printQueue = new PrintQueue();
        Thread thread[] = new Thread[10];
        for (int i = 0; i < thread.length; i++) {
            thread[i] = new Thread(new Job(printQueue));
        }
        for (int i = 0; i < 10; i++) {
            thread[i].start();
            Thread.sleep(100);
        }
    }

}

/**
 * 要执行的任务
 */
class Job implements Runnable {
    PrintQueue printQueue;
    public Job(PrintQueue printQueue) {
        this.printQueue = printQueue;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "开始打印");
        printQueue.printJob(new Object());
        System.out.println(Thread.currentThread().getName() + "打印完毕");
    }
}

/**
 * 执行打印队列
 */
class PrintQueue {
    // 公平锁
//    private Lock queueLock = new ReentrantLock(true);
    // 非公平锁
    private Lock queueLock = new ReentrantLock(false);

    public void printJob(Object document) {
        queueLock.lock();
        try {
            int duration = new Random().nextInt(10) + 1;
            System.out.println(Thread.currentThread().getName() + "正在打印,需要" + duration);
            Thread.sleep(duration * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            queueLock.unlock();
        }

        queueLock.lock();
        try {
            int duration = new Random().nextInt(10) + 1;
            System.out.println(Thread.currentThread().getName() + "正在打印,需要" + duration);
            Thread.sleep(duration * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            queueLock.unlock();
        }
    }
}

运行结果:如果是公平锁,则会第一次获取锁从线程0-9,第二次还是会从0-9,顺序获取锁
image.png

3) 特例

针对tryLock()方法,它是很猛的,它不遵守设定的公平的规则,例如,当 有线程执行tryLock的时候,一旦所有线程都释放了锁,那么这个正在tryLock的线程就能获取到锁,即使在它之前已经有其他线程在等待队列里了。

4) 公平锁和非公平锁优缺点

image.png

5) 源码分析

判断是否队列中有没有线程在排队,有在排队,那么就不会去插队(对公平锁而言)。
image.png

3.4 共享锁和排他锁

1)什么是共享锁和排它锁

排他锁,又称独占锁,独享锁,共享锁,又称为读锁,获得共享锁之后,可以查看但是无法修改和删除数据,其他线程此时也可以获取到共享锁,但也可以查看但无法修改和删除数据,共享锁和排它锁的典型读写锁ReentrantReadWriteLock,其中读锁是共享锁,写锁是独享锁

2) 读写锁的作用

那么读写锁有什么作用呢,在没有读写锁之前,我们假设使用了ReentrantLock,那么虽然我们保证了线程安全,但是也浪费了一定的资源:多个读操作同时进行,并没有线程安全问题,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,提高了程序的执行效率。

3) 读写锁的规则

a)多个线程只申请读锁,都可以申请到
b)如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁。
c)如果一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。
d) 一句话总结:要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现(要么多度,要么一写)

换一种思路更容易理解:读写锁只是一把锁,可以通过两种方式锁定:读锁定和写锁定。读写锁可以同时被一个或者多个线程读锁定,也可以被单一线程写锁定。但是永远不能同时对这把锁进行度锁定和写锁定。
这里是把“获取写锁”理解为“把读写锁进行写锁定”,相当于是换了一种思路,不过原则是不变的,就是要么一个或多个线程同时有读锁(同时读锁定),要么就是一个线程有写锁(进行写锁定),但是两者不会同时出现。

4) ReentrantReadWriteLock具体用法

之前的情况,未使用读写锁
image.png
现在用了读写锁,线程1和线程2可以同时用读锁,提高了效率:

image.png

当线程1和线程2都释放了锁之后,线程3和线程4就可以进行写入了,但是只能有一个线程持有写锁:
image.png

5) 实例

实例:电影院抢票,有的人只是读取也就是来看看不实际买票,可以同时读。

/**
 * 描述: 通过电影院买票例子演示读写锁
 */
public class CinemaReadWrite {
    /** 读写锁 **/
    private static ReentrantReadWriteLock reentrantReadWriteLock
            = new ReentrantReadWriteLock();

    /** 读锁 **/
    private static ReentrantReadWriteLock.ReadLock readLock =
            reentrantReadWriteLock.readLock();

    /** 写锁 **/
    private static ReentrantReadWriteLock.WriteLock writeLock =
            reentrantReadWriteLock.writeLock();

    /**
     * 读方法获取读锁
     */
    private static void read() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了读锁, 正在读取中...");
            Thread.sleep(1000);

        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放了读锁");
            readLock.unlock();

        }
    }

    /**
     * 写方法获取写锁
     */
    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了写锁, 正在写入中...");
            Thread.sleep(1000);

        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放了写锁");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        // 创建四个线程,两个线程调用读方法,两个线程调用写方法。
        new Thread(() -> read(), "Tread1").start();
        new Thread(() -> read(), "Tread2").start();
        new Thread(() -> write(), "Tread3").start();
        new Thread(() -> write(), "Tread4").start();
    }
}

运行结果:
image.png
插队方面:不允许读锁插队,允许降级不允许升级。

6) 读写锁的插队策略

公平锁自然不允许插队。

非公平锁:假设线程2和线程4正在同时读取,线程3想要写入,拿不到锁,于是进入等待队列,线程5不在队列里,现在过来想要读取。有以下两种策略:

策略1:读取可以插队效率高,但是容易造成饥饿,写线程都等待中,造成写线程饥饿

image.png

策略2:避免饥饿读取也不能插队,宁可牺牲一点效率也不能让事故发生。ReentrantReadWriteLock的实现是选择了策略2,是很明智的。image.png

image.png

结论:
公平锁:读写锁都不能插队
非公平锁:写锁可以随时插队,读锁仅在等待队列头结点,不是想获得写锁的线程的时候可以插队。头结点是写就不插队了,如果是读就可以插队。

7) 源码分析

分析读写锁公平锁和非公平锁的插队策略,主要看这两个类:一个表示公平,一个表示了非公平
image.png

公平锁,会去判断是否有线程在排队,来决定是否阻塞。
image.png
非公平锁,在写锁根本不会去判断队列里是否有线程排队,直接返回fasle,不需要阻塞,尝试去插队。
在读锁中,方法名的含义就可以了解判断队列的头结点是否是想获取写锁的线程,那么就会被阻塞,就不允许插队。
image.png

通过IDE我们可以找到readerShouldBlock方法被tryAcquireShared方法调用,根据是否是公平锁来做判断。
image.png

8) 锁的升降级

只支持降级不支持升级

9) 实际锁降级的例子

在锁降级成功后,也就是持有写锁的时候同时申请并获得了读锁后,此时直接释放写锁,但是不释 放读锁,这样就可以提高锁的利用效率,下面这段代码演示了在更新缓存的时候,如何利用锁的降 级功能。

/**
 * 描述:实际锁降级的例子
 *
 * @Author wzy
 * @Date 2020/7/6 18:49
 * @Version V1.0
 **/
public class CachedData {
    Object data;
    volatile boolean cacheValid;
    final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

    void processCachedData() {
        //最开始是读取 
        rwl.readLock().lock();
        if (!cacheValid) {
            //发现缓存失效,那么就需要写入了,所以现在需要获取写锁。由于锁不支持升级 
            rwl.readLock().unlock();
            //获取到写锁 
            rwl.writeLock().lock();
            try {
                //这里需要再次判断数据的有效性,因为在我们释放读锁和获取写锁的空隙之内 
                if (!cacheValid) {
                    data = new Object();
                    cacheValid = true;
                }
                //在不释放写锁的情况下,直接获取读锁,这就是读写锁的降级。 
                rwl.readLock().lock();
            } finally {
                //释放了写锁,但是依然持有读锁,这样一来,就可以多个线程同时读取了, 
                rwl.writeLock().unlock();
            }
        }
        try {
            System.out.println(data);
        } finally {
            //最后释放读锁
            rwl.readLock().unlock();
        }
    }
}

在这段代码中有一个读写锁,最重要的就是中间的 processCachedData 方法,在这个方法中,会首 先获取到读锁,也就是 rwl.readLock().lock(),它去判断当前的缓存是否有效,如果有效那么就直接 跳过整个 if 语句,如果已经失效,代表我们需要更新这个缓存了。由于我们需要更新缓存,所以之 前获取到的读锁是不够用的,我们需要获取写锁。

在获取写锁之前,我们首先释放读锁,然后利用 rwl.writeLock().lock() 来获取到写锁,然后是经典的 try finally 语句,在 try 语句中我们首先判断缓存是否有效,因为在刚才释放读锁和获取写锁的过程 中,可能有其他线程抢先修改了数据,所以在此我们需要进行二次判断。

如果我们发现缓存是无效的,就用 new Object() 这样的方式来示意,获取到了新的数据内容,并把 缓存的标记位设置为 ture,让缓存变得有效。由于我们后续希望打印出 data 的值,所以不能在此处 释放掉所有的锁。我们的选择是在不释放写锁的情况下直接获取读锁,也就是 rwl.readLock().lock() 这行语句所做的事情,然后,在持有读锁的情况下释放写锁,最后,在最下面的 try 中把 data 的值 打印出来。

这就是一个非常典型的利用锁的降级功能的代码。 你可能会想,我为什么要这么麻烦进行降级呢?我一直持有最高等级的写锁不就可以了吗?这样谁 都没办法来影响到我自己的工作,永远是线程安全的。

为什么需要锁的降级?

如果我们在刚才的方法中,一直使用写锁,最后才释放写锁的话,虽然确实是线程安全的,但是也 是没有必要的,因为我们只有一处修改数据的代码:

data = new Object();

后面我们对于 data 仅仅是读取。如果还一直使用写锁的话,就不能让多个线程同时来读取了,持有 写锁是浪费资源的,降低了整体的效率,所以这个时候利用锁的降级是很好的办法,可以提高整体 性能。

10) 为什么不能升级

容易造成死锁,比如说有两个线程都是读线程,都想升级为写锁,但是读写不能同时存在,持有读锁的线程必须等到,所有其他持有读锁的线程都释放读锁才能升级,线程1和线程2都想对方释放,就造成死锁,但也不是完全不可能使用锁升级策略,要保证的就是只有一个锁被升级。

11) 总结

1、ReentrantReadWriteLock实现了ReadWriteLock接口,最主要的有两个方法:readLock()和writeLock()用来获取读锁和写锁。
2、锁申请和释放策略
a) 如果多个线程只申请读锁,都可以申请到。
b)如果一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁。
c)如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。
d)要么一个或者多个线程同时有读锁,要么一个线程有写锁,但是两者不会同时出现。

总结:要么多读,要么一写。

3、插队策略:为了防止饥饿,读锁不能插队
4、升级策略:只能降级,不能升级
5、使用场合:相对于ReentrantLock适合于一般场合,ReentrantReadWriteLock适用于读多写少的情况合理使用可以进一步提高并发效率

4.自旋锁和阻塞锁

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间。
如果同步代码块中的内容过于简单,状态转化消耗的时间可能比用户代码执行时间更长,在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和回复线程的花费可能会让系统得不偿失。
如果物理机器有多个处理器,能够让两个或者两个以上同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
而为了让当前线程“稍等一下”,我们需要让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不比阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是自旋锁。
阻塞锁和自旋锁相反,阻塞锁如果遇到没拿到锁的情况,会直接把线程阻塞,直到被唤醒。

4.1 自旋锁的缺点

如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源,在自旋过程中,一直消耗cpu,虽然自旋锁的起始开销低于悲观锁,但是随着自旋时间的增长,开销也是线性增长的。

4.2 原理和源码分析

在java1.5版本及以上的并发框架java.util.concurrent的atmoic包下的类基本都是自旋锁的实现。AtomicInteger的实现:自旋锁的实现原理是CAS.
AtomicInteger的实现:自旋锁的实现原理是CAS
AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋条件,如果修改过程中遇到其他线程竞争导致没修改成功,就在while里死循环,直至修改成功。

image.png

4.3 自己实现简单自旋锁

使用synchronized来实现简单自旋锁

/**
 * 描述:调用模拟CAS代码
 *
 * @Author wzy
 * @Date 2020/7/7 18:43
 * @Version V1.0
 **/
public class TowThreadCompetition implements Runnable{

    private volatile int value;

    public synchronized int compareAndSwap(int expectedValue, int newValue) {
        int oldValue = value;
        if (oldValue == expectedValue) {
            value = newValue;
        }
        return oldValue;
    }

    @Override
    public void run() {
        compareAndSwap(0, 1);
    }

    public static void main(String[] args) throws InterruptedException {
        TowThreadCompetition r = new TowThreadCompetition();
        r.value = 0;
        Thread r1 = new Thread(r, "Thread-1");
        Thread r2 = new Thread(r, "Thread-2");

        r1.start();
        r2.start();

        r1.join();
        r2.join();

        System.out.println(r.value);
    }
}

运行结果:虽然两个线程并发修改,但是最终结果只修改了一次,其中一个线程操作成功,另一个线程操作失败。
image.png

4.4 自旋锁的适用场景

自旋锁一般用于多核服务器,在并发度不是特别高的情况下,比阻塞锁的效率高。另外自旋锁适用于临界区比较短小的情况,否则如果临界区很大(线程一旦拿到锁,很久才会释放),那也是不合适的。

5.可中断锁

在Java中,synchronized就是不可中断锁,而Lock是可中断锁,因为tryLock(time)和lockInterruptibly都能响应中断。
如果某一线程A正在执行锁中的代码,另一个线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以中断它,这种就是可中断锁。

6.锁优化

6.1虚拟机对锁的优化

  • 自旋锁和自适应
  • 锁消除
  • 锁粗化

自适应自旋即尝试一定次数还没获得锁,就将自旋锁转为阻塞锁,这个可以通过配置JVM进行配置。

6.2 写代码如何优化锁和提高并发性能

1、缩小同步代码块
2、尽量不要锁住方法
3、减少请求锁的次数
4、避免人为制造“热点”
5、锁中尽量不要包含锁,容易造成死锁
6、选择合适的锁类型或合适的工具类