并发编程之原子性

写在前面

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

共享变量

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

原子性

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

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

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

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

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

01.png

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

解决方案

从共享性解决

使用局部变量

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

使用ThreadLocal

ThreadLocal示例

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

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

public class ThreadLocalDemo {
    static ThreadLocal<String> 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();
    }
}

运行结果:

02.png

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

ThreadLocal源码解读
结构
03.png
04.png

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

05.png

类结构图如下:

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<String> tl1 = new ThreadLocal<>();
static ThreadLocal<Integer> 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反编译出来:

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,分代年龄和锁标记位。对象头的存储结构如下:

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并发编程的艺术》

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,042评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,996评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,674评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,340评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,404评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,749评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,902评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,662评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,110评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,451评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,577评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,258评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,848评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,726评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,952评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,271评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,452评论 2 348

推荐阅读更多精彩内容

  • Java8张图 11、字符串不变性 12、equals()方法、hashCode()方法的区别 13、...
    Miley_MOJIE阅读 3,696评论 0 11
  • 一、线程状态转换新建(New)可运行(Runnable)阻塞(Blocking)无限期等待(Waiting)限期等...
    达微阅读 570评论 1 2
  • 进程和线程 进程 所有运行中的任务通常对应一个进程,当一个程序进入内存运行时,即变成一个进程.进程是处于运行过程中...
    胜浩_ae28阅读 5,087评论 0 23
  • 我的家里有两个狗,今天,可可和乐乐在吵架,它们“汪汪”的叫个不停,可我一句也听不懂,于是,我突发奇想,那我不...
    金奕彤阅读 1,237评论 0 2
  • 自昨晚收到某打车软件下的司机师傅在未接单之前的骚扰短信后,再也不敢晚上搭乘顺风车!用户数-1 现在很多用户产品追求...
    小妞变身superwoman阅读 315评论 0 3