并发编程之原子性

写在前面

多线程访问共享变量的时候,很容易出现并发问题。特别是多个线程对共享变量进行写入的时候,由于原子性的问题,很容易导致最后数据的错误。一般来讲,我们可以进行同步,同步的方式就是加锁。另一方面,jdk也提供了变量的线程隔离方式——ThreadLocal,尽管它的出现并不是为了解决上述的问题。

共享变量

何为共享变量?说到这个,我们想一想什么变量不是共享的。在一个线程调用一个方法的时候,会在栈内存上为局部变量和方法参数申请内存,在方法调用结束的时候,这些内存会被释放。不同的线程调用同一个方法都会为局部变量和方法参数copy一个副本,所以栈内存是私有的,也就是说局部变量和方法参数不是线程共享的。而堆上的数组和对象是共享的,堆内存是所有线程可以访问的,也就是说成员变量,静态变量和数组元素是可以共享的

原子性

即不可中断的一个或一系列操作,也就是说一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。在线程级别,我们可以这样说一个或几个操作只能在一个线程执行完之后,另一个线程才能开始执行该操作,也就是说这些操作是不可分割的,线程不能在这些操作上交替执行

举个栗子:i++,这个操作的语义可以拆分成三步:

  • 读取变量i的值
  • 将变量i值加1
  • 将计算结果写入变量i

由于线程是基于处理器分配的时间片执行的,这三个步骤可能让多个线程交叉执行。我们假设i

的初始值为0,如果两个线程按照如下顺序交替执行的话:

并发编程之原子性_第1张图片
01.png

我们看到,经过了两次i++的操作,变量i最后的值是1,并不是想象中的2。这就是因为i++并不是原子性操作所带来的并发问题。

解决方案

从共享性解决

使用局部变量

方法中的方法参数和局部变量是线程私有的,自然不会存在并发问题。

使用ThreadLocal

ThreadLocal示例

ThreadLocal作为变量的线程隔离的类,访问此变量的每个线程都会copy一个此变量的副本。多个线程操作这个变量,实际上是操作的自己本地内存的变量,这样就避免了多线程操作变量的安全问题。

我们先来看一下ThreadLocal的简单使用:

public class ThreadLocalDemo {
    static ThreadLocal tl = new ThreadLocal<>();

    public static void main(String[] args) {
        Thread thread1 = new Thread(()->{
            tl.set("thread 1 locals");
            System.out.println("thread 1 :"+ tl.get());
        });
        Thread thread2 = new Thread(()->{
            tl.set("thread 2 locals");
            System.out.println("thread 2 :"+ tl.get());
        });
        thread1.start();
        thread2.start();
    }
}

运行结果:

并发编程之原子性_第2张图片
02.png

其实无论运行多少次,无论几个线程一起跑,最后打印出来的都是各个线程自己维护在内存里的本地变量,而不会出现线程1设置的变量被线程2修改这种情况。

ThreadLocal源码解读
结构
并发编程之原子性_第3张图片
03.png
并发编程之原子性_第4张图片
04.png

由ThreadLocal和Thread的类结构可知,Thread里面有两个成员变量threadLocalsthreadLocals,他们都是ThreadLocalMap类型的,而ThreadLocalMapThreadLocal的一个静态内部类,这是一个定制化的HashMap。默认每个线程一开始的时候,这两个变量都是null。

并发编程之原子性_第5张图片
05.png

类结构图如下:

并发编程之原子性_第6张图片
06.png
Set方法

ThreadLocal#set:

public void set(T value) {
        Thread t = Thread.currentThread(); //当前线程
        ThreadLocalMap map = getMap(t); //获取当前线程的threadlocals
        if (map != null)
            map.set(this, value); //(k,v)存入ThreadLocalMap
        else
            createMap(t, value); //初始化当前线程的threadlocals,创建ThreadLocalMap
    }

代码的字面意思:如果当前线程的threadLocals变量不为null,则将当前的ThreadLocal实例为key,传入的value值为value,放入ThreadLocalMap对象里面;如果当前线程的threadLocals为null,则初始化当前线程的threadLocals变量。

ThreadLocalMap getMap(Thread t) {
        return t.threadLocals; //返回当前线程的threadLocals
    }
void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue); //初始化当前线程的threadLocals,k为当前的ThreadLocal对象,v为设置的值。
    }

看到这里,说一下为什么为什么会是个map。那是因为一个线程可以绑定多个ThreadLocal实例。例如:

static ThreadLocal tl1 = new ThreadLocal<>();
static ThreadLocal tl2 = new ThreadLocal<>();
Get方法
public T get() {
        Thread t = Thread.currentThread(); //当前线程
        ThreadLocalMap map = getMap(t); //当前线程的threadlocals变量
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this); //取出Entry
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
private T setInitialValue() {
        T value = initialValue(); //初始化为null
        Thread t = Thread.currentThread(); 
        ThreadLocalMap map = getMap(t); //获取当前线程的thredLocals是变量
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }

这段代码的意思是:我先判断当前线程的本地变量threadLocals变量是否为null,不为null则以当前的ThreadLocal实例为key从map中取出Entry,能取出Entry则返回对应的value值;若当前线程的本地变量threadLocals变量为null,则初始化threadLocals变量,初始化的工作和set差不多,只不过set设置的值为传入的参数,初始化设置的value是null(在当前线程的threadLocals变量不为null的时候)。

Remove 方法
public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

就是如果当前线程的 本地变量threadLocals不为null,则删除当前线程中指定 ThreadLocal 实例的本地变量。

总结一下:每个线程都有一个成员变量叫threadLocals,它是ThreadLocalMap类型的。其中的key为每一个ThreadLocal的实例,value为传入的参数值。

注意:

  • 由于ThreadLocalMap的key为WeakReference,在外部没有强引用时,发生GC会回收时,如果创建ThreadLocal一直运行,将会导致这个key对应的value将会一直在内存中得不到回收,发生内存泄露。所以在用完ThreadLocal的时候要注意手动remove。
  • 其实ThreadLocal会有个问题,那就是子线程通过获取不了父线程中的ThreadLocal变量,这个其实java已经给出了解决方案了,就是Thread的另一个ThreadLocalMap类型的变量inheritableThreadLocals,我们通过这个变量,从get方法中能获取到本线程和父线程的ThreadLocal变量。

同步方法解决

话说回来,刚刚我们从共享性角度解决并发编程的原子性问题,提出了ThreadLocal,也就是每个线程独占的,自然不会有并发问题。下面从另一个角度来说,也就是我们都知道的方式:加锁。

锁的概念

《并发编程的艺术》里是这么定义锁的:"锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)"。

提到锁,一大堆名词就冒出来了:内置锁,显示锁,可重入锁,读写锁,以及祖师爷抽象队列同步器(AQS)……

最大名鼎鼎的要属于synchronized的了。

synchronized关键字

synchronized同步关键字,可以修饰方法,使之成为同步方法。可以修饰this或Class对象,使之成为同步代码块。

public 返回类型 方法名(参数列表) {
    synchronized (锁对象) {
        需要保持原子性的一系列代码
        }
}

public synchronized 返回类型 方法名(参数列表) {
    需要被同步执行的代码
}

public synchronized static 返回类型 方法名(参数列表) {
    需要被同步执行的代码
}

例如这个demo:

public class SynchronizedDemo {
    private Object lock = new Object();
    
    public synchronized void m1(){
    }
    public void m2(){
        synchronized (lock) {
        }
    }
}

通过javap反编译出来:

并发编程之原子性_第7张图片
07.png

可以看到,同步代码块的的synchronized是用monitorentermoniteorexit实现的,同步方法看不出来(其实是jvm底层的的ACC_SYNCHRONIZED实现的)。

monitorenter指令对应于同步代码块的开始位置,监视器在这个位置进入,获取锁;moniteorexit指令对应于同步代码块的结束位置,监视器在这个位置退出,释放锁。

JVM需要保证每一个monitorenter都有一个monitorexit与之对应,任何对象都有个monitor与之关联。一旦monitor被持有,这个对象将被锁定。

Java对象头

synchronized用的锁是存在java对象头里的,对象头一般占有2字宽(1字宽为4字节,即32bit),但是如果对象是数组类型,则需要3字宽。对象头里的Mark Word默认存储对象的HashCode,分代年龄和锁标记位。对象头的存储结构如下:

并发编程之原子性_第8张图片
08.png

(此图来源于互联网,侵删)

锁的升级与优化

从jdk1.6开始,对锁进行了进一步的升级和优化。锁一共有4种状态,无锁,偏向锁,轻量级锁,重量级锁,这几种状态会随着竞争情况逐渐升级。锁可以升级但不能降级。

偏向锁

偏向锁的引入背景:只在单线程访问同步块的场景。

当锁不存在多线程竞争的时候,为了让线程获得锁的代价更低引入了偏向锁。当一个线程访问同步快并获取锁时,会在对象头Mark Word上记录偏向锁状态位1,此时的锁标识位是01。

当一个线程获取锁的时候,会先检查Mark Word上的可偏状态,如果是1,则继续检查对象头的线程Id。如果线程Id不是当前线程,则通过CAS竞争获取锁,竞争成功将线程Id替换,如果CAS竞争锁失败,证明存在多线程情况,此时偏向锁被挂起,升级升轻量级锁。如果线程是当先线程,则执行同步代码块。

偏向锁的释放使用了一种等到竞争出现才释放锁的机制,所以当其他线程去竞争锁时,持有偏向锁的线程才会释放锁。此时将恢复到无锁状态或偏向于其他线程。

轻量级锁

轻量级锁的引入背景:没有多线程竞争的前提下,减少重量级锁的互斥产生的性能消耗。

线程在执行同步块之前,JVM会首先在当前线程的栈桢中创建用于存储锁记录的空间,并通过CAS将Mark Word替换为指向锁记录的指针。如果成功,则当前线程获得锁,如果失败,则表示有其他线程竞争锁,此时会自旋等待,并会膨胀成重量级锁。

轻量级锁的释放也是通过CAS来执行的,如果成功,则表示没有竞争发生,如果失败,锁会膨胀成重量级锁。

我们发现,偏向锁相比较轻量级锁通过CAS以及自旋等方式获取锁,性能更好一些,因为他只有在判断对象头中的线程Id不是当前线程的时候才去CAS竞争锁,而轻量级锁一开始就CAS竞争锁了。

重量级锁

重量级锁通过对象内部的monitor实现,当锁处于整个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后才会唤醒这些线程,被唤醒的线程展开新的一轮锁争夺。此时操作系统实现线程之间的切换需要从用户态到内核态的切换,线程切换的成本非常高。

参考资料

方腾飞:《Java并发编程的艺术》

你可能感兴趣的:(并发编程之原子性)