【并发】一文搞懂多线程和并发编程

本文详细介绍了Java线程的创建、运行状态、并发控制(如synchronized、Lock)、线程中断、线程礼让、线程同步优化以及死锁和CAS机制。通过示例展示了线程的使用方法,包括继承Thread、实现Runnable和Callable接口,以及线程的中断、优先级设置和线程状态的观察。同时,探讨了线程同步的锁机制,如synchronized和Lock,以及死锁和ABA问题。
摘要由CSDN通过智能技术生成

线程-Thread

线程是CPU调度和执行的最小单位,一个进程可以拥有一个或多个线程。

在一个进程中,如果开辟了多个线程,线程的运行将由CPU调度器来安排。

在单核CPU下,即使用了多线程,在同一个时间点,CPU只能执行单个线程的代码,因为线程之间切换的很快,所以就感觉多个线程是在同时做一件事。

这种现象也叫做并发

创建线程

在java中,创建线程有三种方式,下面一一介绍:

继承Thread类

第一步,首先定义一个类,继承自Thread类,并重写的Thread的run方法,然后在run方法里面编写线程需要做的事情,代码如下:

/**
 * 继承自Thread,并重写的Thread的run方法
 **/
class MyThread extends Thread {
    @Override
    public void run() {
        // 在这里编写线程需要做的事情
    }
}

第二步,创建MyThread对象,并执行其start方法,即可开启一个线程。

Thread thread = new MyThread();
thread.start();

实现Runnable接口

首先第一步就是创建一个runnable对象,并实现其run方法,然后再通过runnable对象来创建一个Thread对象,并执行其start方法,这样就完成了。我们来看下代码:

// 创建一个runnable对象,并实现其run方法
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        // 在这里编写线程需要做的事情
    }
};
// 通过runnable对象来创建一个Thread对象,并执行其start方法
Thread thread = new Thread(runnable);
thread.start();

这种写法在平时的开发中会用得比较多。

我也更推荐大家使用这样的方式来启动一个线程,因为这种写法可以避免单继承的局限性,更加灵活方便,另外,我们创建的Runnable对象也可以被多个线程同时使用。

实现Callable接口

如果需要拿到线程执行完毕后的返回值可以使用这种方式来创建线程。

运行Callable任务可以拿到一个FutureTask对象,通过这个对象可以拿到异步计算的结果,同时,通过Future对象可以了解任务执行情况,也可以取消任务的执行。

另外,Callable对象的Call方法是可以抛出异常的。

下面我们来看看具体怎么使用这种方式来创建线程。

第一步,定义一个类,实现Callable接口,并实现其call方法,代码如下:

/**
 * 定义一个类,实现Callable接口,并实现其call方法
 **/
class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        // 此处执行线程需要做的事情
        int sum = 0;
        for (int i = 0; i < 100; i++) {
            sum++;
        }
        return sum;
    }
}

第二步,创建一个Callable对象,并使用FutureTask对Callable对象进行封装,

第三步,使用futureTask对象创建一个线程,并启动该线程

第四步,通过futureTask.get()方法获取任务执行完毕后的返回值

代码如下:

public static void startThread() throws ExecutionException, InterruptedException {
        // 创建一个Callable对象
        Callable<Integer> callable = new MyCallable();
        // 使用FutureTask对Callable对象进行封装
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        // 使用futureTask对象创建一个线程,并启动
        Thread thread = new Thread(futureTask);
        thread.start();
        // 获取线程执行完毕后的返回值
        Integer result = futureTask.get();
        Log.i("TAG", "result=" + result);
}

线程的状态

想要观测线程的状态,我们可以通过以下方法进行观测:

Thread.State state = thread.getState();

在 Java 中,线程状态一共有 6 种,如下所示:

NEW - 创建状态

当我们new一个Thread的时候,线程就处于创建状态,比如:

Thread thread = new Thread();

RUNNABLE - 就绪或运行状态

当我们调用线程的start()方法,线程就会进入该状态,如:

thread.start();

调用了线程的 start() 方法,线程一般并不会马上运行,因为它要等待CPU的调度,等CPU觉得可以执行了,线程才能得到执行。

另外,还有两种情况也会使线程进入就绪状态:

  • 线程之前处于阻塞状态,如果阻塞解除了,也会进入就绪状态
  • 线程之前处于运行状态,如果执行了 Thread.yield() 方法,则线程会进入就绪状态

BLOCKED - 阻塞状态

该状态下,线程正在阻塞等待某个锁。

WATING - 等待状态

当调用了以下某一个方法,线程就会处于等待状态:

  • Object.wait
  • Thread.join
  • LockSupport.park

处于等待状态的线程正在等待另一个线程执行特定操作,例如:

  • 在一个对象上调用了 Object.wait() 的线程正在等待另一个线程调用 Object.notify()Object.notifyAll()
  • 已调用 Thread.join() 的线程正在等待指定线程终止。

TIMED_WAITING - 定时等待状态

指定等待时间的等待状态,当调用以下具有指定正等待时间的方法之一,线程就会处于定时等待状态:

  • Thread.sleep(long millis)
  • Object.wait(long timeout)
  • Thread.join(long millis)
  • LockSupport.parkNanos(long nanos)
  • LockSupport .parkUntil(long deadline)

TERMINATED - 终止状态

终止状态,线程已完成执行就会进入此状态。

下面我们看下线程的 6 个状态的关系图:

在这里插入图片描述

线程的方法

下面列举一下线程常用的几个方法:

// 设置线程的优先级
public final void setPriority(int newPriority)
// 等待该线程终止
public final void join() throws InterruptedException
// 检测线程是否处于活动状态
public final boolean isAlive()
// 中断线程(不推荐使用)
public void interrupt()
    
// 在执行的毫秒数内让当前正在执行的线程休眠
public static void sleep(long millis) throws InterruptedException
// 暂停当前正在执行的线程对象,并执行其他线程
public static native void yield()

线程睡眠-sleep

// 在执行的毫秒数内让当前正在执行的线程休眠
public static void sleep(long millis) throws InterruptedException

sleep(long millis):在执行的毫秒数内让当前正在执行的线程休眠执行该方法会使当前线程进入阻塞状态。它的入参表示需要睡眠的毫秒值。

注意看,它是一个static方法,也就意味这我们需要这样来使用:

Thread.sleep(1_000);

这段代码的意思就是让当前执行的线程睡眠1秒。

还要注意都是sleep方法是有可能抛出InterruptedException异常的,因此,我们使用的时候sleep方法的时候需要对InterruptedException异常进行捕获。

另外,还有一个我们应该知道的:每个对象都有一个锁,如果当前线程持有这个对象的锁,当线程调用sleep方法的时候,线程是不会释放这个对象锁的。有别与对象的wait()方法,调用wait()方法是会释放锁的。

线程礼让-yield

// 暂停当前正在执行的线程对象,并执行其他线程
public static native void yield()

执行该方法,可以使得当前线程从运行状态转变为就绪状态

线程礼让是什么意思呢?

就是一个拿到CPU分配的时间片的正在运行中的线程,如果执行了yield()方法,那么该线程将会把执行时间片让出,让CPU重新调度,当前线程会和其他线程一起竞争CPU资源。这也叫做线程礼让,礼让不一定会成功,下一刻也有可能该线程再次获得资源执行。

另外,执行yield()方法不会阻塞当前线程

线程插队-join

// 等待该线程终止
public final void join() throws InterruptedException

当某个线程调用join()方法后,该线程将会立刻拿到CPU时间片,立马执行,即使前面有其他线程正在执行,因此,join()方法可以想象成插队

在线程插队时,其他线程将会被阻塞。

当该线程执行完毕后,其他线程才能继续等待CPU的调度。

注意,join()可能会抛出一个InterruptedException异常,因此使用join()方法的时候需要捕获该异常。

下面我们来举个简单的例子看看join方法是怎么进行插队:

public static void startThread() throws InterruptedException {
    Thread thread = new Thread(new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < 3; i++) {
                Log.i("TAG", "子线程正在插队..name=" + Thread.currentThread().getName());
            }
        }
    }, "插队的子线程");
    thread.start();

    int sum = 0;
    /**
     * 主线程先执行,当sum值为1的时候,让子线程执行join方法插队,
     * 接下来主线程将会把时间片让给插队的子线程,等子线程执行完毕后,
     * 主线程将回到就绪状态等待继续被执行。
     */
    while (true) {
        if (sum == 1) {
            Log.i("TAG", "子线程开始插队..");
            thread.join();
        }
        Log.i("TAG", "主线程正在执行..");
        if (sum == 3) {
            Log.i("TAG", "主线程执行完毕..");
            return;
        }
        sum++;
    }

}

执行如上方法,日志打印如下:

I/TAG: 主线程正在执行..
I/TAG: 子线程开始插队..
I/TAG: 子线程正在插队..name=插队的子线程
I/TAG: 子线程正在插队..name=插队的子线程
I/TAG: 子线程正在插队..name=插队的子线程
I/TAG: 主线程正在执行..
I/TAG: 主线程正在执行..
I/TAG: 主线程正在执行..
I/TAG: 主线程执行完毕..

线程的优先级-Priority

CPU的线程调度器会按照陷程度优先级决定先调度哪个线程来执行。

线程的优先级是用数字来表示的,范围是1~10,另外,线程的默认优先级是5

我们可以通过该方法来设置线程的优先级:

// 设置线程的优先级
public final void setPriority(int newPriority)

守护线程

线程分为用户线程和守护线程,守护线程就是JVM帮我们创建好的线程,如后台记录操作日志线程,垃圾回收线程等。用户线程就是用户自己定义的线程。

虚拟机不必确保守护线程执行完毕,但必须确保用户线程执行完毕

我们可以使用以下代码将用户线程设置为守护线程:

thread.setDaemon(true);

如何停止线程

当线程处于执行中状态时,怎么让线程停下来呢?

在Thread类中,提供了stop或者destroy等方法让我们可以停止线程,但这是不建议的。

我们应该最好让线程自己停下来,线程自己停下来才是最安全的。

不建议使用stop或者destroy等方法停止线程,并且这些方法都已经被官方弃用了,我们来看看官方的对线程的stop()方法的弃用描述:

/**
 * Throws {@code UnsupportedOperationException}.
 *
 * @deprecated This method was originally designed to force a thread to stop
 *       and throw a {@code ThreadDeath} as an exception. It was inherently unsafe.
 *       Stopping a thread with
 *       Thread.stop causes it to unlock all of the monitors that it
 *       has locked (as a natural consequence of the unchecked
 *       <code>ThreadDeath</code> exception propagating up the stack).  If
 *       any of the objects previously protected by these monitors were in
 *       an inconsistent state, the damaged objects become visible to
 *       other threads, potentially resulting in arbitrary behavior.  Many
 *       uses of <code>stop</code> should be replaced by code that simply
 *       modifies some variable to indicate that the target thread should
 *       stop running.  The target thread should check this variable
 *       regularly, and return from its run method in an orderly fashion
 *       if the variable indicates that it is to stop running.  If the
 *       target thread waits for long periods (on a condition variable,
 *       for example), the <code>interrupt</code> method should be used to
 *       interrupt the wait.
 *       For more information, see
 *       <a href="{@docRoot}/../technotes/guides/concurrency/threadPrimitiveDeprecation.html">Why
 *       are Thread.stop, Thread.suspend and Thread.resume Deprecated?</a>.
 */
@Deprecated
public final void stop() {
	// …………………………………………………………………………………………………………………………………………
}

它的大概意思就是:

此方法最初旨在强制线程停止,并抛出 {@code ThreadDeath} 作为异常。它本质上是不安全的

如果使用Thread.stop()停止线程,会导致它解锁已锁定的所有监视器,如果以前受这些监视器保护的任何对象处于不一致状态,则损坏的对象对其他线程可见,可能导致其他不安全的行为。

Thread.stop()应该被替换为只是修改一些变量以指示目标线程应该停止运行的代码,目标线程应该定期检查这个变量,如果变量指示它要停止运行,则以有序的方式从它的run()方法返回

由此可知,官方推荐我们应该使用一个标志位,来标识线程是否应该停止运行,如果标志位指示它要停止运行,则以有序的方式从它的run()方法返回。这才是退出线程的正确方式。

那么下面我们就用代码来模拟一下线程的退出。

首先定义一个类实现Runnable接口,并实现其run方法,run方法中根据标志位判断是否结束,如果标志位为false则run方法执行结束,也就意味着任务已经结束,而改变标志位则通过我们定义的stopRunnable()来改变,代码如下:

class MyRunnable implements Runnable {
    public int value = 0;
    // 标记位,为true时表示可以执行线程,为false时表示线程执行完毕,需要停止
    private boolean flag = true;

    @Override
    public void run() {
        while (flag) {
            Log.i("TAG", "value=" + value++);
        }
    }

    /**
     * 执行该方法表示任务已经执行完毕,需要停止线程
     **/
    public void stopRunnable() {
        flag = false;
    }
}

创建我们刚刚定义好的MyRunnable对象,并开启一个线程执行它。然后使主线程睡眠一秒,睡眠结束后,执行runnable.stopRunnable()方法将线程停止,代码如下:

MyRunnable runnable = new MyRunnable();
new Thread(runnable).start();
// 主线程睡眠1S后,停止线程
Thread.sleep(1000);
runnable.stopRunnable();

线程同步-Synchronized

同一进程下,如果开辟了多个线程,这些线程将会共享同一块存储空间,在带来方便的同时,也带来访问冲突的问题。

为了保证数据在方法中被访问的正确性,在访问时加入了锁机制Synchronized,Synchronized是一个关键字,它包括一下三种用法:

  • synchronized修饰实例方法:作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  • synchronized修饰静态方法:作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  • synchronized修饰某代码块:要指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁

Synchronized方法可以控制对对象的访问,每个对象都有一把锁,每个synchronized方法都必须获得调用该方法的对象的锁才能执行,方法一旦执行,就会独占对象的锁,直到该方法返回才释放锁,锁被释放后,后面被阻塞的线程才能有机会获得对象锁并执行。

这种方式也被称为互斥锁,即能达到互斥访问目的的锁,也就是说当一个共享数据被当前正在访问的线程加上互斥锁后,在同一个时刻,其他线程只能处于等待的状态,直到当前线程处理完毕释放锁。

同时,Synchronized可保证一个线程的变化被其他线程所看到,也就是保证了线程的可见性

虽然我们可以使用Synchronized解决多线程操作共享数据不安全的问题,但是使用线程同步也会导致一些性能问题,如:

  • 在多线程竞争下加锁,释放锁会导致比较多的上下文切换和调度延迟,引起性能问题
  • 如果一个优先级高的线程等待一个优先级低的线程释放锁,会导致优先级倒置,引起性能问题

Synchronized底层原理

Java虚拟机中实现synchronized是基于进入和退出管程(Monitor)对象实现的

管程(Monitor)是什么呢?

这就得从java的对象说起,这对深入理解synchronized实现原理非常关键。

对象在内存中的布局分为三块区域:对象头、实例数据、对齐填充。如下:

在这里插入图片描述

  • 实例数据:存放类的属性数据信息,包括父类的属性信息,这部分内存按4字节对齐
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐
  • 对象头:Synchronized使用的锁对象是存储在Java对象头里的

对于对象头,JVM采用2个字节来存储对象头,其主要结构是由以下两个部分组成(对于数组对象而言,有三部分):

  • Klass Point:指向当前对象的Class对象的内存地址
  • Length:数组对象的长度,只有数组对象的对象头才有Length
  • Mark Word:存储对象的 锁信息HashCode分代年龄GC标志 等信息,它的长度是是32位

Mark Word默认存储结构如下:

HashCode分代年龄是否是偏向锁锁标志位
25bit4bit1bit2bit

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本

考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:

在这里插入图片描述

其中轻量级锁和偏向锁是Java 6 对 synchronized 锁进行优化后新增加的。

其中,重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。

每个对象都有一个monitor与之关联,monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,

当一个monitor被某个线程持有后,它便处于锁定状态

Java虚拟机对synchronized的优化

锁的状态总共有四种:

  • 无锁状态
  • 偏向级锁
  • 轻量级锁
  • 重量级锁

随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

偏向锁

偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段。

经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,也就是说,极有可能连续多次是同一个线程申请相同的锁,因此为了减少同一线程获取锁的代价而引入偏向锁。

偏向锁的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。

但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁。

偏向锁失败后,会升级为轻量级锁。

轻量级锁

当锁升级为轻量级锁后,Mark Word的结构也变为轻量级锁的结构。

轻量级锁所适应的场景是线程交替执行同步块的场合

如果存在同一时间访问同一锁的场合,轻量级锁会升级为重量级锁

自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

自旋锁的优化是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,

操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。

如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。

最后没办法也就只能升级为重量级锁了。

锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底。

Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,

如StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

synchronized的可重入性

从互斥锁的设计上来说,

当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,

但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,

在java中synchronized是基于原子性的内部锁机制,是可重入的,

因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。

线程中断

在线程运行时,也就是执行run方法时,打断线程,就叫做线程中断:

// 中断线程(实例方法)
public void Thread.interrupt();

// 判断线程是否被中断(实例方法)
public boolean Thread.isInterrupted();

// 判断是否被中断并清除当前中断状态(静态方法)
public static boolean Thread.interrupted();

当一个线程处于被阻塞状态或者试图执行一个阻塞操作时,使用Thread.interrupt()方式中断该线程,注意此时将会抛出一个InterruptedException的异常,同时中断状态将会被复位(由中断状态改为非中断状态),如下代码将演示该过程:

处于非阻塞状态的线程需要我们手动进行中断检测并结束程序

非阻塞状态的线程调用interrupt()并不会导致中断状态重置。

简单总结一下中断两种情况:

  • 当线程处于阻塞状态或者试图执行一个阻塞操作时,我们可以使用实例方法interrupt()进行线程中断,并且会抛出interruptException异常(该异常必须捕捉无法向外抛出)并将中断状态复位
  • 当线程处于运行状态时,我们也可调用实例方法interrupt()进行线程中断,但同时必须手动判断中断状态,可以使用isInterrupted()方法来判读,如果是中断状态,需手动编写中断线程的代码来中断线程

事实上,线程的中断操作对于正在等待获取的锁对象的synchronized方法或者代码块并不起作用,

也就是对于synchronized来说,如果一个线程在等待锁,那么结果只有两种:要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效

死锁

多个线程各自占有一些共享资源,并且互相等待其他线程占有的资源才能运行,而导致两个或多个线程都在等待对方释放资源,都停止执行的情况,某一个同步块同时拥有两个以上对象的锁时,就可能发生死锁问题。

产生死锁的四个必要条件:

  • 互斥条件:一个资源每次只能被一个线程使用
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:线程已获得资源,在未使用完之前,不能强行剥夺
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系

下面写个例子来简单的演示一下死锁现象,

我们先开启一个线程A,让它拿到对象A的锁,等待1秒后尝试获获取对象B的锁,

与此同时,再开启一个线程B,先让它拿到对象B的锁,等待1秒后尝试获获取对象A的锁,

这样,两个线程都会等待对方释放对象锁,就产生死锁了。我们看代码:

public static void startThread() {
    Object objectA = new Object();
    Object objectB = new Object();
    // 开启一个线程A,先拿到对象A的锁,等待1秒后尝试获获取对象B的锁
    new Thread(() -> {
        synchronized (objectA) {
            Log.i("TAG", "线程-A拿到了对象A的锁");
            try {
                Thread.sleep(1_000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (objectB) {
                Log.i("TAG", "线程-A拿到了对象B的锁");
            }
        }
    }, "线程-A").start();

    // 开启一个线程B,先拿到对象B的锁,等待1秒后尝试获获取对象A的锁
    new Thread(() -> {
        synchronized (objectB) {
            Log.i("TAG", "线程-B拿到了对象B的锁");
            try {
                Thread.sleep(1_000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (objectA) {
                Log.i("TAG", "线程-B拿到了对象A的锁");
            }
        }
    }, "线程-B").start();
}

日志打印如下:

I/TAG: 线程-A拿到了对象A的锁
I/TAG: 线程-B拿到了对象B的锁

打印该日志后,不管等多久,都不会继续打印其他日志了,说明发生死锁了。

那么,怎么解决这个死锁呢?其实,只需要将里层的synchronized块放到外层就行了:

public static void startThread() {
    Object objectA = new Object();
    Object objectB = new Object();
    // 开启一个线程A,先拿到对象A的锁,等待1秒后尝试获获取对象B的锁
    new Thread(() -> {
        synchronized (objectA) {
            Log.i("TAG", "线程-A拿到了对象A的锁");
            try {
                Thread.sleep(1_000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        synchronized (objectB) {
            Log.i("TAG", "线程-A拿到了对象B的锁");
        }
    }, "线程-A").start();

    // 开启一个线程B,先拿到对象B的锁,等待1秒后尝试获获取对象A的锁
    new Thread(() -> {
        synchronized (objectB) {
            Log.i("TAG", "线程-B拿到了对象B的锁");
            try {
                Thread.sleep(1_000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        synchronized (objectA) {
            Log.i("TAG", "线程-B拿到了对象A的锁");
        }
    }, "线程-B").start();

}

再来看看日志打印:

I/TAG: 线程-A拿到了对象A的锁
I/TAG: 线程-B拿到了对象B的锁
I/TAG: 线程-A拿到了对象B的锁
I/TAG: 线程-B拿到了对象A的锁

Lock锁

从JDK 5.0开始,Java提供了更强大的线程同步机制,Lock,它通过显示定义同步锁对象来实现同步

锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。

Lock是一个接口,而ReentrantLock类则实现了Lock接口,它拥有与synchronized相同的并发性和语义,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显式加锁、释放锁。

Lock是一个可重入锁。

CAS机制

虽然Synchronized确保了线程安全,但是在某些情况下,这并不是一个最优的选择。

Synchronized关键字会让没有得到锁资源的线程进入阻塞状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高

尽管 Java 1.6 对Synchronized做了优化,增加了从偏向锁到轻量级锁再到重量级锁的过度,但是在最终转变为重量级锁之后,性能仍然比较低。

所以面对这种情况,我们就可以使用java为我们提供的原子操作类

所谓原子操作类,指的是一系列以Atomic开头的包装类。如AtomicBoolean,AtomicUInteger,AtomicLong等。

原子操作类一般来说,性能会比Synchronized更好。

而原子操作类的底层原理就是CAS机制

CAS,Compare and Swap,就是比较并替换的意思。CAS机制中使用了3个基本操作数:内存地址V旧的预期值A要修改的新值B

更新一个变量的时候,只有当变量的预期值A和内存地址 V 中的实际值相同时,才会将内存地址V对应的值修改为B。

举个例子:
此时有两个线程对一个变量进行自增操作,这个变量当前的值为10。
线程1和线程2都已经获取到了内存地址的值,对于线程1和线程2来说,旧的预期值为10,要修改的新值为11。
如果线程2先将变量进行了自增,那么这个变量将变成11,即将变量的内存地址的值修改为了11,
此时回到线程1,
在线程1中,拿旧的预期值10和内存地址的值11进行比较,发现不相等,提交失败,此时线程1会将旧的预期值修改为11,要修改的新值修改为12,
然后继续获取内存地址的值,发现是11,这时,再次拿旧的预期值和内存地址的值比较,发现是相等的,则进行将内存地址的值更新为要修改的新值。

从思想上来说,

Synchronized属于悲观锁,悲观的认为程序中的并发情况严重,所以严防死守,

CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去重试更新。

CAS的ABA问题

举个例子说明一下ABA问题。

假设此时有三个线程想使用CAS的方式更新这个变量的值,线程1和线程2已经获取当前值,线程3还未获取当前值。

接下来,线程1先一步执行成功,把当前值成功从A更新为B;
同时 线程2 因为某种原因被阻塞了,没有做更新操作;
而 线程3 在 线程1 更新之后,获取了当前值B,并再次把当前值从B更新成了A;

然后线程2终于恢复了运行状态,由于阻塞之前已经获得了“当前值A”,然后经过判断,发送内存地址中的实际值也是A,所以成功把变量值A更新成了B。

其实,这么看起来好像ABA并没有什么问题,但是在一些特殊的场景中,可能就有问题了,比如银行提款:

假设有一个遵循CAS原理的提款机,小灰有100元存款,要用这个提款机来提款50元。

由于提款机硬件出了点问题,小灰的提款操作被同时提交了两次,开启了两个线程,两个线程都是获取当前值100元,要更新成50元。

理想情况下,应该一个线程更新成功,一个线程更新失败,小灰的存款值被扣一次。

线程1首先执行成功,把余额从100改成50,线程2因为某种原因阻塞。

这时,小灰的妈妈刚好给小灰汇款50元。

线程2仍然是阻塞状态,线程3执行成功,把余额从50改成了100。

然后线程2恢复运行,由于阻塞之前获得了“当前值”100,然后经过判断,此时存款实际值也是100,所以会成功把变量值100更新成50。

小灰的正确余额应该保持100元,结果由于ABA问题,导致余额变成50.

怎么解决呢?其实加个版本号就可以了。

真正要做到严谨的CAS机制,我们在compare阶段不仅要比较期望值A和地址V中的实际值还要比较变量的版本号是否一致

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一场雪ycx

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值