深入浅出学Java(三)-Java线程--线程安全

212 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第19天,点击查看活动详情

前言

在谈线程安全之前我们需要了解CPU和主存之间的关系.

image.png

而高速缓存在解决CPU的内存速度不一致问题的同时,也引起了新的问题,那就是共享数据的不一致性.

一、java线程安全

当多个线程访问某个类时,不管运行时环境采用何种调度方式,或者这些进程如何交替执行,并且在主调代码中不需要任何额外的同步或协同.

这个类都能表现出正确的行为,那么就称这个类是线程安全的.

1.1 原子性

提供了互斥访问,同一时刻只能有一个线程对它进行操作

1.2 可见性

一个线程对主内存的修改可以及时地被其他线程观察到.

1.3 有序性

防止指令重排

二、Synchronized

Synchronized关键字能同时解决程序执行的“原子性”,“可见性”,和“有序性”,当我们获得锁的时候,执行同步代码,线程会被强制从主内存中读取数据,先把主内存的数据复制到本地内存,然后在本地内存进行修改,在释放锁的时候,会把数据写会主内存。

但是这样做的效率无疑很低。

三、volatile

volatile保证了可见性和有序性

3.1 可见性

volatile是一个类型修饰符,它是被设计用来修饰被不同线程访问和修改的变量,可以被异步的线程所修改。

volatile修饰的变量是放到共享内存中的,可以让所有的线程获取和修改。

3.2 有序性

Java内存模型会将volatile修饰的字段,将在写操作后插入一个写屏障 指令,在读操作前插入一个读屏障指令。

如果你对一个volatile字段进行写操作时

  • 1、一旦你完成写入,任何访问这个字段的线程将 会得到最新的值。
  • 2、在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。

3.3 不保证原子性

例如你让一个volatile的integer自增(i++),其实要分成4步:

  • 读取volatile变量值到local;
  • 增加变量的值;
  • 把local的值写回
  • 通过内存屏障让其它的线程可见。
    这4步的jvm指令为:
mov   
0xc(%r10),%r8d
 ; Load
inc   
 %r8d           ; Increment
mov   
 %r8d,0xc(%r10)
 ; Store
lock
 addl $0x0,(%rsp)
 ; StoreLoad Barrier

从Load到store到内存屏障,一共4步。

其中最后一步jvm让这个最新的变量的值在所有线程可见,也就是最后一步让所有的CPU内核都获得了最新的值,但中间的几步(从Load到Store)是不安全的,中间如果其他的CPU修改了值将会丢失。

3.4 总结

所以volatile不能保证i++操作的原子性

四、CAS

CAS英文解释是比较和交换,是cpu底层的源语,是解决共享变量实现方案,它定义了三个变量,内存地址值对应V,期待值E和要修改的值U

image.png

4.1 ABA问题

CAS操作只有内存值与预期值相等才会更新内存中的值,所有CAS操作可能会出现这种现象:

原来内存值为A,线程1和线程2都对i值操作,i初始值为1,线程1使用CAS将i值修改为5,然后又使用CAS将内存值修改回1;

这时线程2使用CAS对i值进行修改时发现内存值仍然是1,然后线程2修改成功。

这种现象是“ABA问题”。

五、Atomic包

Java在Atomic提供了若干原子类,通过CAS操作提供了互斥访问,同一时刻只能有一个线程来对它进行操作.

六、单例

6.1 饿汉式单例

public class Singleton {

    //在类加载的时候就完成了实例化
    private final static Singleton INSTANCE = new Singleton();

    private Singleton(){}

    public static Singleton getInstance(){

        return INSTANCE;

    }

}

因为在类加载时就完成了实例化,从而避免了多线程同步问题.

但是缺点也是很明显

缺点

类加载时就实例化了,没有达到Lazy Loading (懒加载) 的效果,如果该实例没被使用,内存就浪费了

6.2 普通的懒汉式

public class Singleton {

    private static Singleton instance = null;

    private Singleton() {

    }
    public static Singleton getInstance() {

        if (instance == null) {

            instance = new Singleton();

        }
        return instance;

    }
}
缺点

这个单例看似没什么问题,但是如果当存在并发时,A线程和B线程会同时实例化多个单例,实际上是线程不安全的.

6.3 同步方法的懒汉式

public class Singleton {

    private static Singleton instance = null;

    private Singleton() {

    }
    
    public static synchronized Singleton getInstance() {

        if (instance == null) {
              
              instance = new Singleton();

        }

        return instance;
    }

}

通过对方法加上synchronized锁,实现线程安全

缺点

但是我们也在上面提到了synchronized的缺点,让线程同步等待的时间过长,有点消耗时间和性能

6.4 双重检查懒汉式

public class Singleton {

    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {

        if (instance == null) {

            synchronized (Singleton.class) {

                if (instance == null) {

                    instance = new Singleton();

               }
            }
        }
        return instance;
    }

}

可以看到,我们对获取单例的方法并没有加锁,那么是如何保证线程安全的呢?

我们在判断单例对象为空后,加了synchronized锁,但是由于对变量赋值也会涉及到指令重排.总共有三步

  • 1、分配对象的内存地址
  • 2、初始化对象
  • 3、将instance指向初始化对象

如果我们没有使用volatile关键字修饰单例时,可能存在线程A拿到的是线程B已经还没有初始化控件,但已指向该对象的instance对象.

那么为了保证指令的可见性和有序性,我们使用volatile关键字去修饰变量.

七、 ReentrantLock

ReentrantLock是一个可重入的互斥锁,又被称为"独占锁"。

ReentrantLock类实现了lock,它拥有与synchronized相同的并发性和内存语义,但是添加了类似锁投票、定时锁等候和可中断锁等候的一些特性。此外,它还提供了在激烈争用情况下更佳的性能(换句话说,当许多线程都想访问共享资源时,JVM可以花更少的时间来调度线程,把更多时间用在执行线程上)

顾名思义,ReentrantLock锁在同一个时间点只能被一个线程锁持有;而可重入的意思是,ReentrantLock锁,可以被单个线程多次获取.

ReentrantLock分为公平锁和非公平锁。他们的区别提现在获取锁的机制上是否公平。

锁是为了保护竞争资源,防止多个线程同时操作数据而出错。

ReentrantLock在同一个时间点只能被一个线程获取(当某线程获取到锁时,其他线程就必须等待)ReentrantLock是通过一个FIFO的等待队列来管理获取该锁所有线程的,在公平锁的机制下,线程依次排队获取锁,而非公平锁在锁是可获取状态时,不管自己是不是在队列的开头都会获取锁

而关于ReentrantLock的源码分析我们将在下节详细阅读~