不是线程的安全
面试官问:“什么是线程安全”,若是你不能很好的回答,那就请往下看吧。
论语中有句话叫“学而优则仕”,相信不少人都以为是“学习好了能够作官”。然而,这样理解倒是错的。切记望文生义。
同理,“线程安全”也不是指线程的安全,而是指内存的安全。为何如此说呢?这和操做系统有关。
目前主流操做系统都是多任务的,即多个进程同时运行。为了保证安全,每一个进程只能访问分配给本身的内存空间,而不能访问别的进程的,这是由操做系统保障的。
在每一个进程的内存空间中都会有一块特殊的公共区域,一般称为堆(内存)。进程内的全部线程均可以访问到该区域,这就是形成问题的潜在缘由。
假设某个线程把数据处理到一半,以为很累,就去休息了一会,回来准备接着处理,却发现数据已经被修改了,不是本身离开时的样子了。可能被其它线程修改了。
好比把你住的小区看做一个进程,小区里的道路/绿化等就属于公共区域。你拿1万块钱往地上一扔,就回家睡觉去了。睡醒后你打算去把它捡回来,发现钱已经不见了。可能被别人拿走了。
由于公共区域人来人往,你放的东西在没有看管措施时,必定是不安全的。内存中的状况亦然如此。
因此线程安全指的是,在堆内存中的数据因为能够被任何线程访问到,在没有限制的状况下存在被意外修改的风险。
即堆内存空间在没有保护机制的状况下,对多线程来讲是不安全的地方,由于你放进去的数据,可能被别的线程“破坏”。
那咱们该怎么办呢?解决问题的过程其实就是一个取舍的过程,不一样的解决方案有不一样的侧重点。
私有的东西就不应让别人知道
现实中不少人都会把1万块钱藏着掖着,不让无关的人知道,因此根本不可能扔到大马路上。由于这钱是你的私有物品。
在程序中也是这样的,因此操做系统会为每一个线程分配属于它本身的内存空间,一般称为栈内存,其它线程无权访问。这也是由操做系统保障的。
若是一些数据只有某个线程会使用,其它线程不能操做也不须要操做,这些数据就能够放入线程的栈内存中。较为常见的就是局部变量。
java
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类了。
web
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很像呀。再重申一遍,ThreadLocal就是,把一个数据复制N份,每一个线程认领一份,各玩各的,互不影响。
只能看,不能摸
放在公共区域的东西,只是存在潜在的安全风险,并非说必定就不安全。有些东西虽然也在公共区域放着,但也是十分安全的。好比你在大街上放一个上百吨的石头雕像,就很是安全,由于你们都弄不动它。
再好比你去旅游时,常常发现一些珍贵的东西,会被用铁栅栏围起来,上面挂一个牌子,写着“只能看,不能摸”。固然能够国际化一点,“only look,don't touch”。这也是很安全的,由于光看几眼是不可能看坏的。
回到程序里,这种状况就属于,只能读取,不能修改。其实就是常量或只读变量,它们对于多线程是安全的,想改也改不了。
面试
class StudentAssistant {
final double passScore = 60;}
好比把及格分数设定为60分,在前面加上一个final,这样全部线程都动不了它了。这就很安全了。
小节一下:以上三种解决方案,其实都是在“耍花招”。
第一种,找个只有本身知道的地方藏起来,固然安全了。
第二种,每人复制1份,各玩各的,互不影响,固然也安全了。
第三种,更狠了,直接规定,只能读取,禁止修改,固然也安全了。
是否是都在“拈轻怕重”呀。若是这三种方法都解决不了,该怎么办呢?Don't worry,just continue reading。
没有规则,那就先入为主
前面给出的三种方案,有点“理想化”了。现实中的状况实际上是很是混乱嘈杂的,没有规则的。
好比在中午高峰期你去饭店吃饭,进门后发现只剩一个空桌子了,你心想先去点餐吧,回来就坐这里吧。当你点完餐回来后,发现已经被别人捷足先登了。
由于桌子是属于公共区域的物品,任何人均可以坐,那就只能谁先抢到谁坐。虽然你在人群中曾多看了它一眼,但它并不会记住你容颜。
解决方法就不用我说了吧,让一我的在那儿看着座位,其它人去点餐。这样当别人再来的时候,你就能够义正词严的说,“很差意思,这个座位,我,已经占了”。
我再次相信聪明的你已经猜到了我要说的东西了,没错,就是(互斥)锁。
回到程序里,若是公共区域(堆内存)的数据,要被多个线程操做时,为了确保数据的安全(或一致)性,须要在数据旁边放一把锁,要想操做数据,先获取锁再说吧。
假设一个线程来到数据跟前一看,发现锁是空闲的,没有人持有。因而它就拿到了这把锁,而后开始操做数据,干了一会活,累了,就去休息了。
这时,又来了一个线程,发现锁被别人持有着,按照规定,它不能操做数据,由于它没法获得这把锁。固然,它能够选择等待,或放弃,转而去干别的。
第一个线程之因此敢大胆的去睡觉,就是由于它手里拿着锁呢,其它线程是不可能操做数据的。当它回来后继续把数据操做完,就能够把锁给释放了。锁再次回到空闲状态,其它线程就能够来抢这把锁了。仍是谁先抢到锁谁操做数据。
编程
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其实就是乐观锁,上一种方案里的获取锁和释放锁其实就是悲观锁。乐观锁持乐观态度,就是假设个人数据不会被意外修改,若是修改了,就放弃,从头再来。悲观锁持悲观态度,就是假设个人数据必定会被意外修改,那干脆直接加锁得了。
做者观点:安全
前两种属于隔离法,一个是位置隔离,一个是数据隔离。
而后两种是标记法,一个是只读标记,一个是加锁标记。
最后一种是大胆法,先来怼一把试试,若不行从头再来。
对于大胆法,仍是有必要尝试的。有人曾说过,“梦想仍是要有的,万一实现了呢”。
多线程
(END)架构
做者是工做超过10年的码农,如今任架构师。喜欢研究技术,崇尚简单快乐。追求以通俗易懂的语言解说技术,但愿全部的读者都能看懂并记住。下面是公众号和知识星球的二维码,欢迎关注!
并发