Java高级 - 多线程锁和同步问题

前言

继上一篇Java高级 - 多线程必知必会之后,我们继续聊一下多线程编程中经常会遇到的同步问题以及对应的解决方式。

首先提一下内存可见性,因为这个涉及到为什么会出现线程的同步问题。

内存可见性

这里引用一幅图来说明:

引用自: http://concurrent.redspider.group/article/02/6.html

由于在Java里面,Java的内存模型规定所有共享变量都存放在主内存中,而每个线程都会把用到的变量从主内存复制到自己的工作内存中。在线程中读写的变量其实都是自己工作内存的变量。

在种情况下,就有可能存在变量不同步的情况。举个例子:

  1. 定义变量A = 1,然后线程1用到了变量A,这个时候本地内存不存在A,所以从主内存读取 A = 1
  2. 线程1修改变量A = 2, 并且把变量更新到主内存中,此时线程1的A = 2
  3. 线程2获取变量A,这个时候本地内存不存在A,所以从主内存读取A = 2
  4. 线程2修改变量A = 3,然后更新到主内存中,此时线程2的A = 3
  5. 线程1再次获取变量A,由于本地内存已经存在A了,返回A = 2

实际上A已经修改为3了,但是线程1还是只能获取到2,这就是所谓的内存可见性问题。出现这个问题的原因就是没有及时更新最新的值到主内存中。

我们可以使用加锁的方式,还有volatile关键字来解决这个问题。

synchronized关键字

相信这个关键字做Java开发的人经常都会遇到,synchronized是给代码块上锁的一种方式。

通常可以这样做:

public void blockLock() {
    Object o = new Object();
    synchronized (o) {
        // code
    }
}

我们通过给对象o上锁的方式达到互斥的效果,同一个对象锁在某一时刻只能被一个线程所获得。

如果某个线程获得了这个对象的内部锁,那么当其他线程在进入synchronized代码块之前,就需要先获得锁,不然的话就会进入挂起阻塞的状态,等待拿到内部锁的线程在释放锁之后再进行竞争。通过这种方式,在退出同步代码块的时候会把本地内存的变量更新到主内存中,在其他线程再获得锁并执行同步代码块的时候,则会重新从主内存中获取,通过这种方式来保证内存可见性。

而为什么说synchronized是一个比较重量级的同步操作呢?因为使用了synchronized之后,其他没有获得锁的线程会从RUNNABLE状态变成WAITING状态,这种从用户态切换到内核态的操作是很耗时的。还记得我们上一篇文章说到过的吗,切换线程的状态通常涉及到上下文切换,我们需要保存和恢复相应的数据。

因此虽然我们可以使用synchronized关键字来实现同步操作,但是如果用更轻量级的替代方式,还是优先考虑别的,例如volatile关键字。

volatile关键字

volatile关键字的作用就是确保对一个变量的更新对其他线程马上可见。具体来说就是,使用volatile关键字标识的变量,不会再写入到本地内存中,而是直接更新主内存。

我们通过例子来说明:

public class ThreadLocalDemo {

    private int value;

    public int get() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }

}

public class ThreadSafe {
    private volatile int value;
    
    public int getValue() {
        return value;
    }
    
    public void setValue(int value) {
        this.value = value;
    }
}

在第一个类里面我们的value没有用volatile修饰的变量,那么如果两个线程同时对value进行修改,就有可能出现内存不可见的问题。但是如果我们用了volatile修饰,那么无论哪个线程修改了value,在主内存中都会是最新的值。

除了保证内存可见性之外,volatile关键字的另外一个重要作用就是禁止指令重排序。编译器和CPU为了提高指令的执行效率可能会进行指令重排序。这会让代码的执行顺序和我们希望的有所出入,导致结果产生偏差。例如:

private SomeClass object = new SomeClass();	

这里进行了三步:

  • 创建类 SomeClass的实例
  • 初始化 SomeClass的实例
  • 把类SomeClass的实例引用赋值为变量 object

但是指令重排序之后有可能是:

  • 创建 SomeClass 的实例
  • 将 SomeClass 的实例引用赋值给变量 object
  • 初始化 SomeClass的实例

这会导致我们访问 object对象的时候,得到的只是一个内存空间的引用而已。这个对象的初始化工作还没进行!我们没办法获得所需要的的真实数据!

因此我们也要对volatile这个关键字多加留意,在一些可能出错的地方,为了保险起见,还是要加上这个关键字。

原子性操作

既然上面我们说到了指令重排序 和内存可见性,那么还有一个不可不提的概念就是:原子性操作。其实原子性操作的定义是:执行一系列操作的时候,要么这些操作全部执行,要么全部不执行,不存在只执行一部分的情况。

举个例子:

int i = i + 1;	

这一条简单的赋值语句,其实也经过了好几个步骤:

  • 先从寄存器或者缓存中取得变量i
  • 对i执行加一操作
  • 把加一之后的结果赋值给变量i

如果同时有好几个线程执行这个操作,那么有可能在第二步的时候,i的值已经发生了变化,但是没有同步到其他线程中,导致最终结果出错。这个操作就不是一个原子性操作。

我们再举另外一个例子:

public class Test {
    public volatile int inc = 0;

    public void increase() {
        inc++;
    }
    public static void main(String[] args) {
        final Test test = new Test();

        for (int i = 0; i < 10; i++) {
            new Thread() {
                    public void run() {
                        for (int j = 0; j < 1000; j++)
                            test.increase();
                    };
                }.start();
        }
        while (Thread.activeCount() > 1) 
            Thread.yield();
        System.out.println(test.inc);
    }
}

如果线程安全的情况下,这段代码应该是输出10000,但是多次运行会发现,输出结果都是小于10000,因为inc++ 并不是一个原子性操作。这个上面我们已经分析过了。这也侧面反映了volatile关键字不具有原子性

所以我们解决这个问题的方法也很简单,就是上面提到的synchronized和volatile关键字。但是如果用了这种内部锁的机制来保证同步性和原子性,那么并发的效率就会大大降低,因为同一时间只有一个线程能获得内部锁,进而执行对应的操作,其他线程都要等待。

有没有更好的方法来解决这个问题呢?

答案是有的,那就是我们接下来要说的CAS操作

CAS操作

在上面我们提到了,加锁是一个比较耗时的操作,而volatile是轻量级,但是他无法保证写入操作ode原子性,只能保证共享变量的可见性。因此就需要用到CAS,也就是Compare and Swap。 比较更新操作来保证。

一般调用 compareAndSwap() 方法,都需要传入4个参数,分别是:

  • 对象内存位置

  • 对象中的变量的偏移量

  • 变量期望值

  • 变量新的值

然后系统会判断在对象中内存偏移量位置的值是不是期望值,是的话就更新为新的值。这是一个原子性指令。

一般在面试中,提到CAS,面试官都会问一个经典的ABA问题:

假如线程1使用CAS修改初始值为A的变量X,那么线程1首先回去获取当前变量X的值,然后使用CAS操作尝试修改X的值为B,如果CAS操作成功了,那么程序运行一定是正确的吗?

答案是:未必。因为有可能在线程1获取X的值之后,在执行CAS之前,线程2已经使用CAS修改变量的值为B,然后又修改为A,那么这个时候线程1拿到的A的值,其实已经不是一开始的A了。这就是ABA问题。

要避免这个问题,在JDK里面的处理是给每个变量都配备了一个时间戳,判断它到底是不是同一个A。

在Java中,已经提供了实现CAS原理的Unsafe类,我们一起来看看:

boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
boolean compareAndSwapInt(Object o, long offset,int expected,int x);
boolean compareAndSwapLong(Object o, long offset,long expected,long x);

这些方法都是 public native 的,也就是说都是由JVM来实现的,具体实现和CPU、操作系统有关。

这里我们以AtomicInteger类的getAndAdd(int delta)方法为例,来看看Java是如何实现原子操作的。

先看看这个方法的源码:

public final int getAndAdd(int delta) {
    return U.getAndAddInt(this, VALUE, delta);
}

这里的U其实就是一个Unsafe对象:

private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();

所以其实AtomicInteger类的getAndAdd(int delta)方法是调用Unsafe类的方法来实现的:

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

可以看到,内部其实是通过一个while循环来判断CAS操作是不是成功,如果成功更新了value值,那么就return。否则会不断重试。

乐观锁和悲观锁

乐观锁

乐观锁又称为“无锁”。因为乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突,乐观锁通常会使用CAS来保证线程执行的安全性。

由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说乐观锁天生免疫死锁

乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;而悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。

悲观锁

悲观锁就是我们常说的锁。它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。

总结

我们首先从为什么会出现线程安全问题说起,提到了由于Java的内存模型,导致了内存可见性有可能出现的问题。这其实是一个同步问题,因此我们解决的方式就是通过synchronized关键字保证线程之间的互斥性。然后又因为synchronized关键字是重量级的操作,会导致上下文切换加剧性能开销,因此Java提供了另外一个轻量级的解决方式:volatile关键字,它能保证操作的内存可见性,因为他的底层实现就是保证Java内存模型中的缓存和主内存中的值同步。但是又引申出了另外一个问题,就是虽然它能保证内存可见性,但是它不能保证操作的原子性,于是就提到了CAS操作,这是基于JVM的实现,每个操作系统和CPU有可能不完全一致,它的目的就是把改写操作用一条指令完成,底层实现是通过判断内存偏移量期望值 是否一致来决定是否要更新新值。最后,我们还提到了悲观锁和乐观锁,这其实就是对应上面我们说到的几种不同的同步方式。

针对不同的场景,我们可以使用不同的策略来保证多线程之间的共享变量同步问题。

  • 最稳妥,性能开销也最大的是synchronized关键字
  • 比较轻量级,但是不保证操作原子性的是volatile关键字
  • 如果是读取较多,写入较少的情景,不仅需要保证可见性和有序性,还要求保证原子性,那么我们可以考虑使用CAS操作
  • 使用CAS操作的时候要注意:ABA问题,循环时间过长导致CPU开销大的问题

参考

  • 《Java并发编程之美》
  • 《Java并发编程实战》
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java的多线程编程中,线同步是一个非常重要的概念。它用来确保多个线程在访问共享资源时不会产生冲突或竞争条件。在Java中,有几种方式可以实现线同步,其中之一就是使用synchronized关键字。 synchronized关键字可以用于修饰代码块或方法。当我们使用synchronized修饰代码块时,需要指定一个对象作为锁对象。在代码块内部,只有获取了锁对象的线程才能执行代码块中的内容,其他线程则需要等待。 在给定的示例中,synchronized关键字被用于修饰insert方法,这意味着同一时刻只能有一个线程能够执行这个方法。这样就确保了对num变量的访问是安全的,不会出现竞争条件导致数据不一致的情况。 具体来说,当一个线程进入synchronized修饰的insert方法时,它会获取到insertData对象的锁,并执行方法内部的代码。而其他线程则需要等待,直到当前线程释放了锁。这样就保证了对num变量的操作是线程安全的。 需要注意的是,synchronized关键字只能保证同一时刻只有一个线程能够执行被修饰的代码块或方法,但并不能保证线程的执行顺序。所以在多线程编程中,我们还需要考虑到线程的调度和执行顺序的不确定性。 除了synchronized关键字,Java还提供了其他的线同步机制,比如Lock接口,它提供了更灵活和细粒度的线同步控制。但是在大部分情况下,synchronized关键字已经能够满足我们的需求,使用它来实现线同步是一种简单而有效的方式。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *3* [Java高级特性 -线程基础(3)线同步](https://blog.csdn.net/weixin_52034200/article/details/130253687)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *2* [EduCoder Java高级特性 -线程基础(3)线同步](https://blog.csdn.net/weixin_45981481/article/details/114494972)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值