【锁机制】synchronized和ReentrantLock、锁优化

java中的锁的种类很多。目前对这部分知识点只是停留在了解的基础上,就目前知识进行梳理,并长期补充。

java中锁常用的为synchronized 和java.util.concurrent.Lock下的锁。

这是一篇对各种分类锁的介绍:https://blog.csdn.net/u013256816/article/details/51204385 

下面对java中实现的锁进行个人分析

Synchronized

这是java中的一种隐式锁,顾名思义,用于保证多线程之间的同步和互斥问题。又称为同步锁或者监视器锁。

synchronized为方法提供一种同步机制,即当线程进入带有锁的方法中时,会获得该锁,若其他线程想要访问该方法,必须拿到这个锁对象,如果没有,那么就会线程阻塞等待锁的释放。

互斥性

synchronize是互斥锁,一个锁同时只能被一个线程持有。

可重入性

若同步代码块中线程,想要再次进入拥有同一个锁的方法中,那么就需要有可重入特性。synchronize提供这种特性,是一种个可冲入锁,当线程想要再次进入同步代码块时,会维护一个计数器,每进入一次,计数器加1,退出一次代码块就减一,直到计数器为0时,释放掉该锁。

作用范围:代码块,方法,静态方法,Class。

锁的粒度问题:synchronized的粒度是可变的,修改方法的时候该方法保持该锁,当我们需要细化粒度就可以使用同步代码块。当锁是一个对象时,该锁就是对象锁,监视是对象,若是静态方法或者Class类,那么就是类锁,监视该类的所有对象。

 

java.util.concurrent.locks.Lock

当java并发的情况变得越来复杂的时候,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();
}

Lock接口是锁的抽象接口,定义了锁的一些基本功能:加锁,释放,可中断,可循环的,定时的等等。

使用Lock接口下的锁都必须显示的加锁 lock()和释放锁 unlock()。

ReentrantLock

可重入锁可以说是synchronized功能相同的一个替代品,并且有更强大的功能。比如:可中断响应、锁申请等待限时、公平锁,可以结合Condition使用。

ReentrantLock加锁必须显示上锁并且释放锁,拥有可重入机制,每次重入计数器都会加一。并且提供了公平锁机制

    ReentrantLock reentrantLock = new ReentrantLock(true);

在代码中为True就是公平锁,会根据线程的先后顺序获得锁。

公平锁和非公平锁

公平锁是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来一次获得锁。

公平锁的好处是等待锁的线程不会饿死,但是整体效率相对低一些;非公平锁的好处是整体效率相对高一些,但是有些线程可能会饿死或者说很早就在等待锁,但要等很久才会获得锁。其中的原因是公平锁是严格按照请求所的顺序来排队获得锁的,而非公平锁时可以抢占的,即如果在某个时刻有线程需要获取锁,而这个时候刚好锁可用,那么这个线程会直接抢占,而这时阻塞在等待队列的线程则不会被唤醒。

公平锁可以使用new ReentrantLock(true)实现。

公平锁和非公平锁实现的核心:

当选择公平锁时,线程在尝试获取锁之前进行一次CAS运算,当且仅当当前锁处于空闲状态并且排队等候锁的队列里没有其他线程的时候,该线程可以获得锁;否则进入队列进行等待。

当选择非公平锁时,线程在尝试获取锁之前进行两次CAS运算,如果发现所空闲,则直接获得锁,如果两次cas运算都未能获得锁的情况下,该线程才进入等候队列。

公平锁和非公平锁在说的获取上都使用到了 volatile 关键字修饰的state字段, 这是保证多线程环境下锁的获取与否的核心。 
但是当并发情况下多个线程都读取到 state == 0时,则必须用到CAS技术,一门CPU的原子锁技术,可通过CPU对共享变量加锁的形式,实现数据变更的原子操作。 
volatile 和 CAS的结合是并发抢占的关键。

可中断响应机制:

对于synchronized锁机制,要么获取到锁执行,要么持续等待。那么就有可能出现死锁的情况,而ReentrantLock可以有效避免这种情况,利用lockinterruptibly()方法,该方法允许优先响应中断,而不是优先获取普通锁和可重入锁。

public class KillDeadlock implements Runnable{
    public static ReentrantLock lock1 = new ReentrantLock();
    public static ReentrantLock lock2 = new ReentrantLock();
    int lock;

    public KillDeadlock(int lock) {
        this.lock = lock;
    }

    @Override
    public void run() {
        try {
            if (lock == 1) {
                lock1.lockInterruptibly();  // 以可以响应中断的方式加锁
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {}
                lock2.lockInterruptibly();
            } else {
                lock2.lockInterruptibly();  // 以可以响应中断的方式加锁
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {}
                lock1.lockInterruptibly();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            if (lock1.isHeldByCurrentThread()) lock1.unlock();  // 注意判断方式
            if (lock2.isHeldByCurrentThread()) lock2.unlock();
            System.err.println(Thread.currentThread().getId() + "退出!");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        KillDeadlock deadLock1 = new KillDeadlock(1);
        KillDeadlock deadLock2 = new KillDeadlock(2);
        Thread t1 = new Thread(deadLock1);
        Thread t2 = new Thread(deadLock2);
        t1.start();t2.start();
        Thread.sleep(1000);
        t2.interrupt(); // ③
    }
}
  • 看上面这个例子,线程2持有锁2请求锁1,线程1持有锁1请求锁2,如果用lock() unlock()会出现线程死锁。
  • t1、t2线程开始运行时,会分别持有lock1和lock2而请求lock2和lock1,这样就发生了死锁。但是,在③处给t2线程状态标记为中断后,持有重入锁lock2的线程t2会响应中断,并不再继续等待lock1,同时释放了其原本持有的lock2,这样t1获取到了lock2,正常执行完成。t2也会退出,但只是释放了资源并没有完成工作。
  • 即lockInterruptibly()方法允许在等待锁的同时,利用Thread.interrupt()方法中断线程的等待,直接返回。lock()方法是不可以的。
  • ReentrantLock.lockInterruptibly允许在等待时由其它线程调用等待线程的Thread.interrupt方法来中断等待线程的等待而直接返回,这时不用获取锁,而会抛出一个InterruptedException。 ReentrantLock.lock方法不允许Thread.interrupt中断,即使检测到Thread.isInterrupted,一样会继续尝试获取锁,失败则继续休眠。只是在最后获取锁成功后再把当前线程置为interrupted状态,然后再中断线程。

锁优化

这里的锁优化主要是指 JVM 对 synchronized 的优化。

自旋锁

互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。

自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。

在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。

锁消除

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。

锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。

对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作:

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。

锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。

上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。

轻量级锁

JDK 1.6 引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。

以下是 HotSpot 虚拟机对象头的内存布局,这些数据被称为 Mark Word。其中 tag bits 对应了五个状态,这些状态在右侧的 state 表格中给出。除了 marked for gc 状态,其它四个状态已经在前面介绍过了。

下图左侧是一个线程的虚拟机栈,其中有一部分称为 Lock Record 的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的 Mark Word。而右侧就是一个锁对象,包含了 Mark Word 和其它信息。

轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。

当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。

如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。

偏向锁

偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。

当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。

当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。

猜你喜欢

转载自blog.csdn.net/weixin_38035852/article/details/81586094