线程-并发-锁

本文详细探讨了Java并发编程中的锁机制,包括乐观锁CAS算法、无锁效率、悲观锁synchronized的工作原理,以及锁的四种状态:无锁、偏向锁、轻量级锁和重量级锁。此外,还分析了公平锁和非公平锁的差异以及ReentrantLock的实现细节。通过对锁的深入理解,有助于提升多线程环境下的程序性能。
摘要由CSDN通过智能技术生成

在操作系统中总会提到进程和线程,那么进程和线程的区别是什么呢?

  • 进程(Process)是系统进行资源分配和调度的基本单位。
  • 线程(thread)是操作系统能够进行运算调度的最小单位。

这是百度上对进程和线程的解释,看起来依然不是很容易理解。事实上,计算机的核心是CPU,它是计算机系统的运算和控制核心,是信息处理、程序运行的最终执行单元,但是每个CPU一次只能处理一个任务,即运行一个进程。如果在运行一个进程的同时,想要运行另外一个进程,就需要先把当前的进程挂起,进行上下文切换,才能转而去运行其他进程。

在一个进程中,要想提高执行效率,可以通过在一个进程中创建多个线程,多个线程并发执行,线程之间可以进行资源共享,但是多个线程在修改同一个资源时,可能会出现错误。

public class Test3 implements Runnable {
   
    private static int ticketNum = 10;

    public static void main(String[] args) {
   
        Test3 test3 = new Test3();
        new Thread(test3,"小一").start();
        new Thread(test3,"小二").start();
        new Thread(test3,"小三").start();
    }

    @Override
    public void run() {
   
        while (true) {
   
            if (ticketNum <= 0) {
   
                break;
            }
            try {
   
                Thread.sleep(200);
            }catch (InterruptedException e) {
   
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "买到了第" + ticketNum-- + "张票");
        }
    }
}

小二买到了第9张票
小三买到了第8张票
小一买到了第10张票
小一买到了第7张票
小三买到了第6张票
小二买到了第5张票
小二买到了第3张票
小三买到了第4张票
小一买到了第4张票
小二买到了第2张票
小一买到了第1张票
小三买到了第0张票

结果中可以看到,有两个人同时买到了一张票,为了避免出现这种情况,我们在线程竞争同一资源时引入了锁这个概念。

并行:在同一时刻只能有一条指令执行,多个进程指令被快速的轮换执行。(CPU数量)(一个CPU执行一个进程)
并发:在同一时刻,有多条指令在多个处理器上同时执行。(解决同一个问题)(在同一个CPU上)

1.线程是否对同步资源加锁

1.1 不加锁:乐观锁

----乐观锁:对数据冲突保持乐观,认为当前线程在使用数据时,不会有其他线程修改数据,所以不对数据加锁。
----只在提交更新的数据时,才会通过某种手段来判断是否有其他线程修改了数据,最常采用的是CAS算法。
----如果数据没有被修改,当前线程就将自己修改的数据成功写入。如果数据已经被其他线程修改,则根据不同的实现方式执行不同的操作(如报错或自动重试)。

1.1.1 CAS

CAS全称Compare And Swap(比较与交换),是一种无锁算法。

CAS算法涉及到三个操作数:

  • 需要读写的内存值 V。
  • 进行比较的值 A。
  • 要写入的新值 B。

当且仅当 V = A 时,CAS通过原子方式将V值更新为B(“比较+更新”整体是一个原子操作),否则不执行任何操作。一般情况下,“更新”是一个不断重试的操作。

通过automicInteger源码来看一下:

public class AtomicInteger extends Number implements java.io.Serializable {
   
    private static final long serialVersionUID = 6214790243416807050L;
    // setup to use Unsafe.compareAndSwapInt for updates
    private static final jdk.internal.misc.Unsafe U= jdk.internal.misc.Unsafe.getUnsafe();
    private static final long VALUE = U.objectFieldOffset(AtomicInteger.class,"value");
    private volatile int value;
  • jdk.internal.misc.Unsafe:用于获取并操作内存中的数据。
  • value:存储 AtomicInteger 的 int 值,该属性借助 volatile 关键字保证其在线程间可见。
  • VALUE:存储 value 在 AtomicInteger 中的偏移量。

进入 automicInteger 的 incrementAndGet() 方法,底层调用的 U.getAndAddInt()。

public final int incrementAndGet() {
   
    return U.getAndAddInt(this, VALUE, 1) + 1;
}

进入 getAndAddInt 方法:

public final int getAndAddInt(Object o, long offset, int delta) {
   
    int v;
    do {
   
        v = getIntVolatile(o, offset);
    } while(!weakCompareAndSet(o, offset, v, v+ delta));
    return v;
}

进入 weakCompareAndSet 方法:

public final boolean weakCompareAndSet(Object o, long offset, int expected,int x) {
   
    return compareAndSetInt(o,offset,expected,x);
}
  • getIntVolatile:获取对象中 offset 偏移地址对应的整型 field 的值,即内存值 V;
  • excepted:期望值 A;
  • 若两值相等,就跳出 while 循环,返回期望值 v,并将内存值更新为 v+delta;若两值不等,就取消赋值,返回 false。

举例:
----内存中的值是 1,通过 getIntVolatile 方法将内存中的值赋值给 v,此时 v=1;
----如果此时有其他线程将内存中的值更改为 3,在调用 weakCompareAndSetInt 方法时,内存中的值 3 和期望的值 1 不相等,重新通过 getIntVolatile 方法给将内存中最新的值赋值给 v,此时 v=3;
----如果没有其他线程更改,在调用 weakCompareAndSetInt 时,内存中的值 3 和期望的值 3 相等,就 return v=3 就可以了。哈哈哈,很简单吧!

1.1.2 为什么无锁效率高

  • 无锁情况下,即使重试失败,线程始终在高速运行,而 synchronized会让线程在没有获得锁时,发生上下文切换,进入阻塞,而上下文切换代价较大。
  • 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,没有额外 CPU 支持时,线程虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。
  • 所以无锁适用于线程数量少,多核CPU的情况。

1.1.3 CAS存在的三个问题

  1. ABA问题。

CAS在更新内存中的值时,首先要检查该值是否被更改,没有被更改才会更新内存值。如果内存中的值原来是A,变成了B,又变成了A,CAS检查时发现值没有变化,但实际已经变化了。

  • ABA问题的解决思路:在变量前添加版本号,每次变量更新时都把版本号加1,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
  • JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题,具体操作封装在 compareAndSet() 中。compareAndSet() 首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果相等,则以原子方式更新引用值和标志的值。
  1. 循环时间长开销大。

CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。

  1. 只能保证一个共享变量的原子操作。

对一个共享变量执行操作时,CAS 能够保证原子操作,但是对多个共享变量操作时,CAS 无法保证操作的原子性。

1.2 加锁

悲观锁:对数据冲突保持悲观,认为当前线程在使用数据时,一定会有其他线程修改数据,所以在获取数据前先对数据加锁,确保数据不会被其他线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。

2.锁住同步资源失败,线程是否阻塞

2.1 阻塞

Wait:释放锁
Sleep:不释放锁

2.2 不阻塞

阻塞或唤醒一个线程需要上下文切换,这个过程非常耗费处理器时间。如果同步代码块中的内容过于简单,上下文切换消耗的时间可能比用户代码执行的时间还长,这样就得不偿失。

如果计算机有多个CPU,可以同时运行多个线程,就让请求锁的线程暂时保留 CPU 时间片,等待持有锁的线程释放锁。这个等待的过程就是自旋,如果在自旋完成后,前面锁定同步资源的线程已经释放了锁,那么当前线程就不必阻塞,直接获取同步资源,从而避免上下文切换的开销,这就是自旋锁。

  • 虽然自旋锁能够节省上下文切换开销,但是也不能代替阻塞,因为它占用处理器时间。
  • 如果锁被占用的时间很短,自旋等待的效果就很好。反之,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源。
  • 所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)还没有成功获得锁,就应当挂起线程。

自旋锁的实现原理是 CAS,AtomicInteger 中的自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

3.多个线程竞争同步资源的流程

3.1 Synchronized如何实现线程同步?

首先了解一下对象结构:
对象结构
epoch:本质是一个时间戳,代表偏向锁的有效性。

  1. Java对象头

synchronized 是悲观锁,需要在操作同步资源之前给同步资源先加锁,这把锁在 Java 对象头里。
Java 对象头又是什么呢?以Hotspot虚拟机为例,对象头主要包括两部分:Mark Word(标记字段)、Class Pointer(类型指针)。

  • Mark Word:用于存储对象自身的运行时数据。

这些信息都与对象自身定义无关,所以Mark Word被设计成一个非固定的数据结构,以便在极小的空间内存中存储尽量多的数据。它会根据对象的状态复用自己的存储空间,即在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

  • Class Pointer:对象指向它的类元数据的指针。

虚拟机通过这个指针来确定这个对象是哪个类的实例。

  1. Monitor

Monitor是线程私有的数据结构,每个线程都有一个monitor record列表和一个全局的可用列表。

  • 每一个被锁住的对象都会关联一个 monitor,monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

synchronized 通过 Monitor 实现线程同步,Monitor 依赖于底层的操作系统的Mutex Lock(互斥锁)实现线程同步。这就是synchronized最初实现同步的方式,也是 JDK 6 之前 synchronized 效率低的原因。这种依赖于操作系统Mutex Lock实现的锁称为“重量级锁”,JDK 6 中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

  • 目前锁共有4种状态,级别从低到高:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。

如果使用 synchronized 给对象加重量级锁,该对象头的 Mark Word 中就被设置为指向Monitor对象的指针。
在这里插入图片描述

  1. 开始时 Monitor 中的 Owner 为 null;
  2. 当 Thread-2 执行 synchronized(obj){} 代码时就会将 Owner 设置为 Thread-2,加锁成功,
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值