线程安全:内存的安全

如果代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么代码就是线程安全的。

一、简述

tip:既要保证线程安全又要尽可能提升性能,怎么取得平衡
1️⃣分析业务场景,尽可能的实现无锁编程。
2️⃣对于并发场景,尽可能的缩小锁的范围。

论语中有句话叫“学而优则仕”,相信很多人都觉得是“学习好了可以做官”。然而,这样理解却是错的,切忌望文生义。同理,“线程安全”也不是指线程的安全,而是指内存的安全。为什么如此说呢?这和操作系统有关。

目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问其他进程的,这是由操作系统保障的。在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成问题的潜在原因。假设某个线程把数据处理到一半,觉得很累,就去休息了一会,回来准备接着处理,却发现数据已经被修改了,不是自己离开时的样子了。可能被其它线程修改了。

比如把你看作一个进程,小区里的道路/绿化等就属于公共区域。你拿1万块钱往地上一扔,就回家睡觉去了。睡醒后你打算去把它捡回来,发现钱已经不见了。可能被别人拿走了。因为公共区域人来人往,你放的东西在没有看管措施时,一定是不安全的。内存中的情况亦然如此。

所以线程安全指的是,在堆内存中的数据由于可以被任何线程访问到,在没有限制的情况下存在被意外修改的风险。即堆内存空间在没有保护机制的情况下,对多线程来说是不安全的地方,因为你放进去的数据,可能被别的线程“破坏”。

那该怎么办呢?解决问题的过程其实就是一个取舍的过程,不同的解决方案有不同的侧重点。

二、私有的东西就不该让别人知道

现实中很多人都会把1万块钱藏着掖着,不让无关的人知道,所以根本不可能扔到大马路上。因为这钱是你的私有物品。

在程序中也是这样的,所以操作系统会为每个线程分配属于它自己的内存空间,通常称为栈内存,其它线程无权访问。这也是由操作系统保障的。

如果一些数据只有某个线程会使用,其它线程不能操作也不需要操作,这些数据就可以放入线程的栈内存中。较为常见的就是局部变量。

public double avgScore(double[] scores) {
    double sum = 0;
    for (double score : scores) {
        sum += score;
    }
    int count = scores.length;
    double avg = sum / count;
    return avg;
}

这里的变量 sum,count,avg 都是局部变量,它们都会被分配在线程栈内存中。

假如现在A线程来执行这个方法,这些变量会在A的栈内存分配。与此同时,B线程也来执行这个方法,这些变量也会在B的栈内存中分配。也就是说这些局部变量会在每个线程的栈内存中都分配一份。由于线程的栈内存只能自己访问,所以栈内存中的变量只属于自己,其它线程根本就不知道。

就像每个人的家只属于自己,其他人不能进来。所以你把1万块钱放到家里,其他人是不会知道的。且一般还会放到某个房间里,而不是扔在客厅的桌子上。所以把自己的东西放到自己的私人地盘,是安全的,因为其他人无法知道。而且越隐私的地方越好。

三、大家不要抢,人人有份

上面的解决方案是基于“位置”的。因为你放东西的“位置”只有你自己知道(或能到达),所以东西是安全的,因此这份安全是由“位置”来保障的。

在程序里就对应于方法的局部变量。局部变量之所以是安全的,就是因为定义它的“位置”是在方法里。这样一来安全是达到了,但是它的使用范围也就被限制在这个方法里了,其它方法想用也用不了。

现实中往往会有一个变量需要多个方法都能够使用的情况,此时定义这个变量的“位置”就不能在方法里面了,而应该在方法外面。即从(方法的)局部变量变为(类的)成员变量,其实就是“位置”发生了变化。那么按照主流编程语言的规定,类的成员变量不能再分配在线程的栈内存中,而应该分配在公共的堆内存中。其实也就是变量在内存中的“位置”发生了变化,由一个私有区域来到了公共区域。因此潜在的安全风险也随之而来。

那怎么保证在公共区域的东西安全呢?答案就是,大家不要抢,人人有份。设想你在街头免费发放矿泉水,来了1万人。你只有1千瓶水,结果可想而知,一拥而上,场面失守。如果你有10万瓶水,大家一看水多着呢,不用着急,一个个排着队来,因为肯定会领到。东西多了自然就不值钱了,从另一个角度来说,也就安全了。大街上的共享单车很安全,因为到处都是,都长得一样,所以连搞破坏的人都放弃了。因此要让一个东西安全,就疯狂的 copy 它吧。

回到程序里,要让公共区域堆内存中的数据对于每个线程都是安全的,那就每个线程都拷贝它一份,每个线程只处理自己的这一份拷贝而不去影响别的线程的,这不就安全了嘛。这就是 ThreadLocal 类。

class StudentAssistant {
    ThreadLocal<String> realName = new ThreadLocal<>();
    ThreadLocal<Double> totalScore = new ThreadLocal<>();
    String determineDegree() {
        double score = totalScore.get();
        if (score >= 90) {
            return "A";
        }
        if (score >= 80) {
            return "B";
        }
        if (score >= 70) {
            return "C";
        }
        if (score >= 60) {
            return "D";
        }
        return "E";
    }
    double determineOptionalcourseScore() {
        double score = totalScore.get();
        if (score >= 90) {
            return 10;
        }
        if (score >= 80) {
            return 20;
        }
        if (score >= 70) {
            return 30;
        }
        if (score >= 60) {
            return 40;
        }
        return 60;
    }
}

这个学生助手类有两个成员变量,realName 和 totalScore,都是 ThreadLocal 类型的。每个线程在运行时都会拷贝一份存储到自己的本地。

A线程运行的是“张三”和“90”,那么这两个数据“张三”和“90”是存储到A线程对象(Thread 类的实例对象)的成员变量里去了。假设此时B线程也在运行,是“李四”和“85”,那么“李四”和“85”这两个数据是存储到了B线程对象(Thread 类的实例对象)的成员变量里去了。

线程类(Thread)有一个成员变量,类似于 Map 类型的,专门用于存储 ThreadLocal 类型的数据。从逻辑从属关系来讲,这些 ThreadLocal 数据是属于 Thread 类的成员变量级别的。从所在“位置”的角度来讲,这些 ThreadLocal 数据是分配在公共区域的堆内存中的。

说的直白一些,就是把堆内存中的一个数据复制N份,每个线程认领1份,同时规定好,每个线程只能玩自己的那份,不准影响别人的。

需要说明的是这N份数据都还是存储在公共区域堆内存里的,经常听到的“线程本地”,是从逻辑从属关系上来讲的,这些数据和线程一一对应,仿佛成了线程自己“领地”的东西了。其实从数据所在“位置”的角度来讲,它们都位于公共的堆内存中,只不过被线程认领了而已。

其实就像大街上的共享单车。原来只有1辆,大家抢着骑,老出问题。现在从这1辆复制出N辆,每人1辆,各骑各的,问题得解。共享单车就是数据,你就是线程。骑行期间,这辆单车从逻辑上来讲是属于你的,从所在位置上来讲还是在大街上这个公共区域的,因为你发现每个小区大门口都贴着“共享单车,禁止入门”。

类比于共享单车,ThreadLocal 就是把一个数据复制N份,每个线程认领一份,各玩各的,互不影响。

四、只能看,不能摸

放在公共区域的东西,只是存在潜在的安全风险,并不是说一定就不安全。有些东西虽然也在公共区域放着,但也是十分安全的。比如大街上一个上百吨的石头雕像,就非常安全,因为大家都弄不动它。

再比如去旅游时,经常发现一些珍贵的东西,会被用铁栅栏围起来,上面挂一个牌子,写着“只能看,不能摸”。当然可以国际化一点,“Only look,don't touch”。这也是很安全的,因为光看几眼是不可能看坏的。

回到程序里,这种情况就属于,只能读取,不能修改。其实就是常量或只读变量,它们对于多线程是安全的,想改也改不了。

class StudentAssistant {
    final double passScore = 60;
}

比如把及格分数设定为60分,在前面加上一个 final,这样所有线程都动不了它了。这就很安全了。

五、小节一下:以上三种解决方案,其实都是在“耍花招”。

第①种,找个只有自己知道的地方藏起来,安全。
第②种,每人复制1份,各玩各的,互不影响,也安全。
第③种,更狠了,直接规定,只能读取,禁止修改,也安全。

如果这三种方法都解决不了,该怎么办?

六、没有规则,那就先入为主

前面给出的三种方案,有点“理想化”了。现实中的情况其实是非常复杂,没有规则的。比如去饭店吃饭,进门后发现只剩一个空桌子,你心想先去点餐,回来就坐这里。当点餐回来后,发现已经被别人捷足先登了。因为桌子属于公共区域的物品,任何人都可以坐,那就只能谁先抢到谁坐。虽然你在人群中曾多看了它一眼,但它并不会记住你容颜。

解决方法就是让一个人在那儿看着座位,其它人去点餐。这样当别人再来的时候,你就可以理直气壮的说:“不好意思,这个座位,我已经占了”。相信你已经猜到了我要说的东西了,没错,就是(互斥)锁

回到程序里,如果公共区域【堆内存】的数据,要被多个线程操作时,为了确保数据的安全(或一致)性,需要在数据旁边放一把锁,要想操作数据,得先获取锁。

假设一个线程来到数据跟前一看,发现锁是空闲的,没有人持有。于是它就拿到了这把锁,然后开始操作数据,干了一会活,就去休息了。这时,又来了一个线程,发现锁被别人持有着,它不能操作数据,因为它无法得到这把锁。当然,它可以选择等待或放弃,转而去干别的。

第一个线程之所以敢大胆的去睡觉,就是因为它手里拿着锁,其它线程是不可能操作数据的。当它回来后继续把数据操作完,就可以把锁给释放了。锁再次回到空闲状态,其它线程就可以来抢这把锁了。还是谁先抢到锁谁操作数据。

class ClassAssistant {
     double totalScore = 60;
     final Lock lock = new Lock();
     void addScore(double score) {
         lock.obtain();
         totalScore += score;
         lock.release();
     }
     void subScore(double score) {
         lock.obtain();
         totalScore -= score;
         lock.release();
     }
 }

假定一个班级的初始分数是60分,这个班级抽出10名学生来同时参加10个不同的答题节目,每个学生答对一次为班级加上5分,答错一次减去5分。因为10个学生一起进行,所以这一定是一个并发情形。因此加分和减分这两个方法被并发的调用,它们共同操作总分数。为了保证数据的一致性,需要在每次操作前先获取锁,操作完成后再释放锁。

再回到最初的例子,你往地上扔1万块钱,是不是一定会丢呢?这要看情况了,如果是在人来人往的都市,可以说肯定会丢的。如果你跑到无人区扔地上,可以说肯定不会丢。都是把东西无保护的放到公共区域里,结果却相差很大。这说明安全问题还和公共区域的环境状况有关系。

比如我把数据放到公共区域的堆内存中,但是始终都只会有1个线程,也就是单线程模型,那这数据肯定是安全的。再者说,2个线程操作同一个数据和200个线程操作同一个数据,这个数据的安全概率是完全不一样的。肯定线程越多数据不安全的概率越大,线程越少数据不安全的概率越小。极限情况,只有1个线程,那不安全概率就是0,也就是安全的。

由此,引出CAS。既然锁可以解决问题,那就用锁得了,为啥又冒出了个 CAS 呢?

那是因为锁的获取和释放是要花费一定代价的,如果在线程数目特别少的时候,可能根本就不会有别的线程来操作数据,此时还要获取锁和释放锁,浪费资源。

针对这种“地广人稀”的情况,专门提出了一种方法,叫 CAS【Compare And Swap】。就是在并发很小的情况下,数据被意外修改的概率很低,但是又存在这种可能性,此时就用 CAS。

假如一个线程操作数据,干了一半活,想要去休息。于是它记录下当前数据的状态(就是数据的值),回家睡觉了。

醒来后打算继续接着干活,但是又担心数据可能被修改了,于是就把睡觉前保存的数据状态拿出来和现在的数据状态比较一下。如果一样,说明自己在睡觉期间,数据没有被人动过(当然也有可能是先被改成了其它,然后又改回来了,这就是 ABA 问题了),那就接着继续干。如果不一样,说明数据已经被修改了,那之前做的那些操作其实都白瞎了,就干脆放弃,从头重新开始处理一遍。

所以 CAS 这种方式适用于并发量不高的情况,也就是数据被意外修改的可能性较小的情况。如果并发量很高的话,数据一定会被修改,每次都要放弃,然后从头再来,这样反而花费的代价更大了,还不如直接加锁。

这里再解释下 ABA 问题,假如你睡觉前数据是5,醒来后数据还是5,并不能肯定数据没有被修改过。可能数据先被修改成8,然后又改回到5。对于这个问题,其实也很好解决,再加一个版本号字段就行了,并规定只要修改数据,必须使版本号加1。

这样你睡觉前数据是5版本号是0,醒来后数据是5版本号是0,表明数据没有被修改。如果数据是5版本号是2,表明数据被改动了2次,先改为其它,然后又改回到5。

CAS 其实就是乐观锁,上一种方案里的获取锁和释放锁其实就是悲观锁。乐观锁持乐观态度,就是假设数据不会被意外修改,如果修改了,就放弃,从头再来。悲观锁持悲观态度,就是假设数据一定会被意外修改,而采取直接加锁的方式

七、线程安全级别

1️⃣不可变
String、Integer、Long 这些,都是是 final 类型的类,任何一个线程都改变不了它们的值,要改变除非新创建一个。不可变对象保证了对象的内存可见性,因此不需要任何同步手段就可以直接在多线程环境下使用,提升了代码执行效率。

2️⃣绝对线程安全
绝对线程安全就是不管运行时环境如何,调用者都不需要额外的同步措施。要做到这一点,通常需要付出许多额外的代价。Java 中标注自己是线程安全的类,绝大多数都不是线程安全的。Java 中也有绝对线程安全的类,比如:CopyOnWriteArrayList、CopyOnWriteArraySet

3️⃣相对线程安全
相对线程安全也就是通常意义上所说的线程安全,像 Vector 这种,add、remove 方法都是原子操作,不会被打断,但也仅限于此,如果有个线程在遍历某个 Vector、有个线程同时在 add 这个 Vector,99%的情况下都会出现ConcurrentModificationException,也就是fail-fast机制

4️⃣线程非安全
ArrayListLinkedListHashMap 等都是线程非安全的类。

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

推荐阅读更多精彩内容