面试笔记——线程安全

sychronized的底层原理

Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。
对象锁的互斥功能是由jvm提供的Monitor(由C++语言实现)实现的,通过 javap -v xx.class 查看class字节码信息,如图:
在这里插入图片描述
第二个monitorexit是为了防止代码抛了异常之后能同样释放对象锁(在底层隐式地使用了try-finally,正常代码抛了异常,通过第二个monitorexit释放锁)。

Monitor的内部属性和功能:
在这里插入图片描述
线程获得锁需要对象(锁)(如上图中的lock)关联monitor。

  • Owner:存储当前获取锁的线程的,只能有一个线程可以获取
  • EntryList:关联没有抢到锁的线程,处于Blocked状态的线程
  • WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程

Monitor实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。

在HotSpot虚拟机中,对象在内存中存储可分为:对象头(Header)、实例数据(Instance Data)和对齐填充,如图:
在这里插入图片描述
32位的对象头信息包括:
在这里插入图片描述

  • hashcode:25位的对象标识Hash码
  • age:对象分代年龄占4位
  • biased_lock:偏向锁标识,占1位 ,0表示没有开始偏向锁,1表示开启了偏向锁
  • thread:持有偏向锁的线程ID,占23位
  • epoch:偏向时间戳,占2位
  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针,占30位
  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针,占30位

对象和Monitor的关联: 每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针 (记录在ptr_to_lock_record中)。

在很多的情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁,其流程为:

  • 加锁流程
    1. 在线程栈中创建一个Lock Record,将其obj字段指向锁对象。
    2. 通过CAS指令(原子操作)将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
    3. 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。
    4. 如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁。
  • 解锁过程
    1. 遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。
    2. 如果Lock Record的Mark Word为null,代表这是一次重入,将obj设置为null后continue。
    3. 如果Lock Record的 Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为无锁状态。如果失败则膨胀为重量级锁。

如,对同一个对象锁进行重入时,就采取的是轻量锁,代码如下:
在这里插入图片描述
加锁过程如下:
在未进入对象锁前,object的存储信息(此时是无锁状态)为:
在这里插入图片描述
当执行到method1时,就会创建一个锁记录——Lock Record,每个线程的栈帧都包含着一个锁记录结构,在锁记录中会存储锁定对象的Mark Word,由Object reference指向对象:
在这里插入图片描述

当前线程持有锁之后,会用CAS交换Mark Word和Lock Record的数据,表示该线程拥有了该对象锁:
在这里插入图片描述
当method2执行时,会发生锁重入,则直接在线程中添加一个Lock Record就可以了,但是第一次已经把Mark Word记录到了Lock Record中,所以在锁重入时,只需要添加记录就可以(但还是进行CAS操作,每加一个锁记录都会进行CAS操作),然后指向对象:
在这里插入图片描述
解锁时:
遍历线程栈的Lock Record,若Lock Record的Mark Word为null,则代表这是一次重入,删除掉Lock Record就可以了;若Lock Record的 Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为无锁状态。

轻量级锁的特点: 在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。

Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。

总结:
在这里插入图片描述
PS:只要锁发生了竞争,都会升级为重量级锁。

Java 内存模型

JMM(Java Memory Model)Java内存模型,定义了共享内存多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性。JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)。线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存。
在这里插入图片描述

CAS

CAS的全称是: Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
在JUC( java.util.concurrent )包下实现的很多类都用到了CAS操作,如 AbstractQueuedSynchronizer(AQS框架)和AtomicXXX类。
CAS数据交换流程:一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当旧的预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,通过自旋(即再从共享内存中去拿值V)的方式等待并再次尝试,直到成功。

CAS的优缺点:

  • 因为没有加锁,所以线程不会陷入阻塞,效率较高
  • 如果竞争激烈,重试频繁发生,效率会受影响

补充——乐观锁和悲观锁:

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗(即,自旋)。
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

CAS 底层实现:CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令,由C++实现的。
ReentrantLock中的一段CAS代码:
在这里插入图片描述

volatile

一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  • 保证线程间的可见性
  • 禁止进行指令重排序

线程间的可见性: 用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见。

举例

public class ForeverLoop {
    static boolean stop = false;
    public static void main(String[] args) {
        new Thread(() -> {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            stop = true;
            System.out.println(Thread.currentThread().getName()+":modify stop to true...");
        },"t1").start();

        new Thread(() -> {
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName()+":"+stop);
        },"t2").start();

        new Thread(() -> {
            int i = 0;
            while (!stop) {
                i++;
            }
            System.out.println("stopped... c:"+ i);
        },"t3").start();
    }
}

在默认情况下,执行上面的代码时的输出为:

t1:modify stop to true...
t2:true

此时的第三个线程依然在执行
疑问:根据第一个和第二个线程的执行结果看,不同线程可以访问由volatile关键字修饰的变量,但是为什么第三个线程还在继续执行呢?
答案如下:在JVM虚拟机中有一个JIT(即时编译器)给代码做了优化(while这个代码块执行的次数太多,直接把条件改成固定的条件):
在这里插入图片描述
解决方案:

  • 在程序运行的时候加入vm参数 -Xint 表示禁用即时编译器,不推荐,得不偿失(其他程序还要使用)
    在这里插入图片描述
  • 在修饰stop变量的时候加上volatile,当前告诉JIT,不要对 volatile 修饰的变量做优化
static volatile boolean stop = false;

禁止指令重排序: 用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。
举例:
在这里插入图片描述

在这里插入图片描述volatile使volatile使用技巧:

  • 写变量让volatile修饰的变量的在代码最后位置
  • 读变量让volatile修饰的变量的在代码最开始位置

AQS

AbstractQueuedSynchronizer,即抽象队列同步器。是一种锁机制,它是做为一个基础框架使用的,像ReentrantLock、Semaphore都是基于AQS实现的。
AQS与Synchronized的区别:

synchronized AQS
关键字,c++ 语言实现 java 语言实现
悲观锁,自动释放锁 悲观锁,手动开启和关闭
锁竞争激烈都是重量级锁,性能差 锁竞争激烈的情况下,提供了多种解决方案

AQS常见的实现类:

  • ReentrantLock 阻塞式锁
  • Semaphore 信号量
  • CountDownLatch 倒计时锁

AQS基本工作机制:
在这里插入图片描述
在AQS中有一个由volatile修饰的变量state来表示状态,若线程0想要持有锁,则需要将state的状态由0改为1,表示该线程持有锁;若此时线程1想要获得锁,但此时state状态为1,会请求失败,则会将线程1加入到FIFO队列中,进行等待;线程2同理;若线程0释放锁后,则会把锁分配给FIFO中的head所指向的线程——线程1。

若多个线程同时抢资源时的情况: 如当state的状态还是0,此时多个线程请求该资源,则通过CAS设置state状态,保证操作的原子性,没有抢到资源的线程从尾部添加到FIFO队列中。

关于AQS是公平锁还是非公平锁:
根据AQS不同的实现方式,AQS既可以实现非公平锁也可以实现公平锁:

  • 新的线程与队列中的线程共同来抢资源,是非公平锁
  • 新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁

ReentrantLock实现原理

ReentrantLock是可重入锁,相对于synchronized它具备以下特点:

  • 可中断
  • 可以设置超时时间
  • 可以设置公平锁
  • 支持多个条件变量
  • 与synchronized一样,都支持重入

ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似。
构造方法接受一个可选的公平参数**(默认非公平锁)** ,当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

ReentrantLock的构造方法:

public ReentrantLock() {
    sync = new NonfairSync();
}

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

NonfairSync 继承自 AQS:

abstract static class Sync extends AbstractQueuedSynchronizer {}

在这里插入图片描述

  • 线程来抢锁后使用cas的方式修改state状态,修改状态成功为1,则让exclusiveOwnerThread属性指向当前线程,获取锁成功
  • 假如修改状态失败,则会进入双向队列中等待,head指向双向队列头部,tail指向双向队列尾部
  • 当exclusiveOwnerThread为null的时候,则会唤醒在双向队列中等待的线程
  • 公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁

综上:

  • ReentrantLock表示支持重新进入的锁,调用 lock 方法获取了锁之后,再次调用 lock,是不会再阻塞
  • ReentrantLock主要利用CAS+AQS队列来实现
  • 支持公平锁和非公平锁,在提供的构造器的中无参默认是非公平锁,也可以传参设置为公平锁

ReentrantLock的功能测试:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockTest {

    //创建锁对象
    static ReentrantLock lock = new ReentrantLock();
    //条件1
    static Condition c1 = lock.newCondition();
    //条件2
    static Condition c2 = lock.newCondition();

    public static void main(String[] args) throws InterruptedException {

        //可打断
//        lockInterrupt();

        //可超时
//        timeOutLock();

        //多条件变量
        conditionTest();

    }

    /**
     * 多条件变量
     */
    public static void conditionTest(){
        new Thread(() -> {
            lock.lock();
            try {
                //进入c1条件的等待
                c1.await();
                System.out.println(Thread.currentThread().getName()+",acquire lock...");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
        }, "t1").start();
        new Thread(() -> {
            lock.lock();
            try {
                //进入c2条件的等待
                c2.await();
                System.out.println(Thread.currentThread().getName()+",acquire lock...");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
        }, "t2").start();

        new Thread(() -> {
            lock.lock();
            try {
                //唤醒因等待c1条件的所有线程
                //c1.signalAll();
                //唤醒c1条件的一个线程
                c1.signal();
                //唤醒c2条件的线程
                c2.signal();
                System.out.println(Thread.currentThread().getName()+",acquire lock...");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
        }, "t3").start();


    }

    /**
     * 锁超时
     * @throws InterruptedException
     */
    public static void timeOutLock() throws InterruptedException {

        Thread t1 = new Thread(() -> {
            //尝试获取锁,如果获取锁成功,返回true,否则返回false
            try {
                if (!lock.tryLock(2, TimeUnit.SECONDS)) {
                    System.out.println("t1-获取锁失败");
                    return;
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            try {
                System.out.println("t1线程-获得了锁");
            } finally {
                lock.unlock();
            }
        }, "t1");

        lock.lock();
        System.out.println("主线程获得了锁");
        t1.start();
        try {
            Thread.sleep(3000);
        } finally {
            lock.unlock();
        }
    }

    /**
     * 可打断
     * @throws InterruptedException
     */
    public static void lockInterrupt() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            try {
                //开启可中断的锁

                lock.lockInterruptibly();
            } catch (InterruptedException e) {
                e.printStackTrace();
                System.out.println("等待的过程中被打断");
                return;
            }
            try {
                System.out.println(Thread.currentThread().getName() + ",获得了锁");
            } finally {
                lock.unlock();
            }
        }, "t1");
        lock.lock();
        System.out.println("主线程获得了锁");
        t1.start();

        try {
            Thread.sleep(1000);
            t1.interrupt();
            System.out.println("执行打断");
        } finally {
            lock.unlock();
        }
    }
}

synchronized和Lock的区别

  • 语法层面
    • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
    • Lock 是接口,源码由 jdk 提供,用 java 语言实现
    • 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
  • 功能层面
    • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
    • Lock 提供了许多 synchronized 不具备的功能,例如公平锁、可打断、可超时、多条件变量
    • Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock(读写锁)
  • 性能层面
    • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
    • 在竞争激烈时,Lock 的实现通常会提供更好的性能

死锁产生的条件

死锁是指在多线程或多进程的系统中,每个进程或线程都在等待其他进程或线程释放资源,导致它们都无法继续执行的状态。简而言之,它是一种资源竞争的情况,其中每个进程或线程都在等待其他资源的释放,而同时也不释放自己的资源,从而导致所有的进程或线程都陷入了僵局,无法继续执行下去。

比如,进程1持有资源A,等待资源B,而进程2持有资源B,等待资源A。这时候,如果1和2都不释放自己的资源,它们就会陷入死锁状态,无法继续向下执行。

Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
    synchronized (A) {
        System.out.println(Thread.currentThread().getName()+"-lock A");
        try {  
            sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        synchronized (B) {
            System.out.println(Thread.currentThread().getName()+"-lock B");
            System.out.println(Thread.currentThread().getName()+"-操作...");
        }
    }
}, "t1");

Thread t2 = new Thread(() -> {
    synchronized (B) {
        System.out.println(Thread.currentThread().getName()+"-lock B");
        try {
            sleep(500);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        synchronized (A) {
            System.out.println(Thread.currentThread().getName()+"-lock A");
            System.out.println(Thread.currentThread().getName()+"-操作...");
        }
    }
}, "t2");
t1.start();
t2.start();

判断死锁的工具:

  • 可以通过jdk自带的jps和jstack来判断是否发生死锁:
    • jps:输出JVM中运行的进程状态信息
    • jstack:查看java进程内线程的堆栈信息
  • jconsole
    • 用于对jvm的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具
    • 打开方式:java 安装目录 bin目录下 直接启动 jconsole.exe 就行
  • VisualVM:故障处理工具
    • 能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈
    • 打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe就行

ConcurrentHashMap

ConcurrentHashMap 是一种线程安全的Map。
底层数据结构:

  • JDK1.7底层采用分段的数组+链表实现
  • JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。

JDK1.7中ConcurrentHashMap:
在这里插入图片描述
添加数据的过程:
在这里插入图片描述
JDK1.8中ConcurrentHashMap:
数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表

采用 CAS + Synchronized来保证并发安全进行实现:

  • CAS控制数组节点的添加
  • synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题 , 效率得到提升

在这里插入图片描述
加锁的方式

  • JDK1.7采用Segment分段锁,底层使用的是ReentrantLock
  • JDK1.8采用CAS添加新节点,采用synchronized锁定链表或红黑二叉树的首节点,相对Segment分段锁粒度更细,性能更好

Java程序如何保证多线程安全

避免并发程序出现问题,从Java并发编

上一篇:Backblaze发布2024 Q1硬盘故障质量报告-2-低AFR与小置信区间的组合有助于识别在当前环境表现良好的硬盘型号。目前,我们主要关注较大容量的硬盘作为更换、迁移目标或新安装之选。利用上述表格,尝试找出表现最佳的12TB、14TB和16TB硬盘型号。 按照硬盘容量分组,并按终身AFR进行排序,接下来逐一审视这些组别: 12TB硬盘型号:三款12TB HGST型号表现优异 14TB硬盘型号:前三款型号——WDC(WUH721414ALE6L4)、东芝(MG07ACA14TA)和希捷(ST14000NM001G)看起来非常可靠。剩余两款型号的终身AFR平平,且置信区间不尽人意。 16TB硬盘型号:选择众多,所有六款型号至今表现良好,其中西部数据的型号表现最佳。 参考文献:https://www.backblaze.com/blog/ 如果您看完有所受益,欢迎点击文章底部左下角“关注”并点击“分享”、“在看”,非常感谢! 精彩推荐:


下一篇:5.合并两个有序数组-题目解答