当前位置: 首页 > 科技观察

如果你这样回答“什么是线程安全”,面试官都会对你刮目相看

时间:2023-03-16 21:23:15 科技观察

如果你回答“什么是线程安全”,面试官会对你刮目相看。《论语》中有句话叫做“学好才能做官”。相信很多人都认为“好好读书,可以当官”。然而,这种理解是错误的。记住字面意思。同理,“线程安全”并不是指线程安全,而是指内存安全。你为什么这么说?这与操作系统有关。在每个进程的内存空间中,都会有一块特殊的公共区域,通常称为堆(memory)。进程内的所有线程都可以访问这个区域,这是问题的潜在原因。假设一个线程处理数据到一半觉得很累,就去休息了一会儿,回来准备处理,却发现数据被修改了,和离开时不一样了。可能会被其他线程修改。比如把你住的小区看成一个过程,小区里面的道路/绿化属于公共区域。你拿一万元扔在地上,然后回家睡觉。醒来后打算取回,发现钱不见了。可能是被别人拿走了。因为公共区域人来人往,你放的东西没有监管措施肯定是不安全的。内存中也是如此。所以线程安全是指堆内存中的数据可以被任何线程访问,没有限制就存在被意外修改的风险。即堆内存空间对于没有保护机制的多线程来说是一个不安全的地方,因为你放入的数据可能会被其他线程“破坏”。那我们该怎么办呢?解决问题的过程其实就是一个取舍的过程,不同的解决方案有不同的侧重点。私事不能让别人知道。现实中,很多人会把1万元钱藏起来,不让无关的人知道,扔在路上是不可能的。因为这笔钱是你的私有财产。在程序中也是如此,所以操作系统会为每个线程分配自己的内存空间,通常称为栈内存,其他线程无权访问。这也是由操作系统保证的。如果有些数据只能被某个线程使用,而其他线程不能也不需要操作,可以将这些数据放在线程的栈内存中。比较常见的是局部变量。doubleavgScore(double[]scores){doublesum=0;for(doublescore:scores){sum+=score;}intcount=scores.length;doubleavg=sum/count;returnavg;}这里的变量sum,count,avg都是局部变量,它们都会被分配到线程栈内存中。如果线程A现在执行这个方法,这些变量将被分配到A的栈内存中。同时B线程也执行这个方法,这些变量也会分配到B的栈内存中。也就是说,这些局部变量会分配在各个线程的栈内存中。由于线程的栈内存只能自己访问,所以栈内存中的变量只属于自己,其他线程根本不知道。就像每个人的家都只属于自己,其他人是进不来的。所以你在家里放了1万元,其他人是不会知道的。通常在一个房间里,而不是在客厅的桌子上。所以把自己的东西放在自己的私人空间里是安全的,因为别人无法知道。而且越私密越好。不抢大家,大家有份相信聪明的你已经发现了上面的解决方案是基于“位置”的。因为只有你自己知道(或能到达)你放东西的“位置”,所以东西是安全的,所以这个安全是由“位置”来保证的。在程序中,它对应于方法的局部变量。局部变量之所以安全,是因为定义它们的“位置”在方法中。这样就实现了安全性,但是它的使用范围仅限于这种方法,不需要其他方法。现实中经常会出现一个变量需要被多个方法使用的情况。这时候定义这个变量的“位置”不能在方法内部,而应该在方法外部。即从(方法的)局部变量变为(类的)成员变量,实际上意味着“位置”发生了变化。那么,按照主流编程语言的规定,类的成员变量就不能再分配在线程的栈内存中,而应该分配在公共堆内存中。实际上,变量在内存中的“位置”发生了变化,从私有区域变成了公共区域。因此,潜在的安全隐患也随之而来。那么如何保证公共区域的物品安全呢?答案是,不要抢,人人有份。想象一下,你在大街上免费发放矿泉水,来了10000人,而你只有1000瓶水。可想而知,人潮涌动,景象坠落。但是如果你有10万瓶水,大家都会看到水多,所以不用着急,一一排上去,因为你一定会拿到的。东西多了,自然就不值钱了。从另一个角度来说,他们也是安全的。街上的共享单车现在很安全,因为太多了,到处都是,而且看起来都一样,所以连破坏者都放弃了。所以为了安全起见,疯狂地复制它。回到程序中,把publicareaheapmemory中的数据对每个线程都做成安全的,然后每个线程拷贝一份,每个线程只处理自己的拷贝,不影响其他线程,这样不安全吗。相信大家已经猜到了,我要表达的是ThreadLocal这个类。classStudentAssistant{ThreadLocalrealName=newThreadLocal<>();ThreadLocaltotalScore=newThreadLocal<>();StringdetermineDegree(){doublescore=totalScore.get();if(score>=90){返回“A”;}if(score>=80){return"B";}if(score>=70){return"C";}if(score>=60){return"D";}return"E";}doubledetermineOptionalcourseScore(){doublescore=totalScore.get();if(score>=90){return10;}if(score>=80){return20;}if(score>=70){return30;}if(score>=60){return40;}return60;}}这个助教类有两个成员变量realName和totalScore,都是ThreadLocal类型。每个线程都会在运行时将存储的副本复制到自己的本地。线程A运行的是“张三”和“90”,所以“张三”和“90”这两个数据存放在A线程对象(Thread类的实例对象)的成员变量中。假设此时B线程也在运行,是“李四”和“85”,那么“李四”和“85”这两个数据存放在B线程对象(实例对象)的成员变量中Thread类的)向上。线程类(Thread)有一个成员变量,类似于Map类型,专门用来存放ThreadLocal类型的数据。这些ThreadLocal数据从逻辑从属关系来说,属于Thread类的成员变量级别。从“位置”的角度来看,这些ThreadLocal数据都分配在公共区域的堆内存中。说白了就是把堆内存中的一段数据拷贝N份,每个线程各索取一份。同时规定每个线程只能玩自己的share,不允许影响别人。需要注意的是,这N份数据仍然保存在公共区域的堆内存中。经常听到的“threadlocal”是逻辑从属关系上的。这些数据和线程一一对应,就好像它们自己变成了线程一样。“领土”的事情也是如此。其实从数据的“位置”来看,它们都位于公共堆内存中,只不过被线程认领了。我想强调这一点。其实就像共享单车上街一样。原来只有一辆车,大家争先恐后地骑,一直出问题。现在从这台复制N辆车,每人一辆,每人自己骑,问题就解决了。共享单车是数据,你是线程。骑行过程中,按理说这辆自行车是你的,而且从位置上来说,还是在街边的公共区域,因为你发现每个小区门口都贴着“共享单车,禁止入内”。哈哈哈哈。共享单车类似于ThreadLocal?重申一下,ThreadLocal就是将一份数据拷贝N份,每个线程各索取一份,各自玩自己的游戏,互不影响。公共区域的东西只能看,不能摸,但存在安全隐患,不一定不安全。虽然有些东西也放在了公共区域,但是也很安全。比如你把一个几百吨重的石像放在大街上,那是很安全的,因为谁也搬不动。再比如,你去旅行的时候,经常会发现一些珍贵的东西,会用铁栅栏把它们围起来,上面还会挂一个牌子,上面写着“只能看,不能摸”。当然可以国际化,“只看,不摸”。也很安全,因为光看是不可能看出不好的东西的。回到程序中,是这样的,只能读取,不能修改。实际上,它们是常量或只读变量。它们对于多线程是安全的,并且不能根据需要进行更改。classStudentAssistant{finaldoublepassScore=60;}比如设置及格分数为60分,在前面加一个final,这样所有线程都动不了。那很安全。一小节:以上三种方案其实都是在“耍花招”。首先,找一个只有自己知道的地方藏起来,当然安全。第二种方式是每人副本1份,玩不同游戏互不影响。当然,它也是安全的。第三种比较狠。直接规定只允许阅读,禁止修改。当然,它也是安全的。都是“避重就轻”吗?这三种方法都不行怎么办?别担心,继续阅读。如果没有规则,那么先入为主的想法。上面给出的三个选项都有些“理想化”了。真实的情况其实很乱很吵,没有规矩。比如你在中午高峰时间去饭店吃饭,进门发现只剩下一张空桌子。你想先点菜,回来坐这里。当你点完单回来,发现已经有人先登机了。因为桌子是公共区域的物品,任何人都可以坐在上面,所以谁先抢到谁就可以坐。虽然你在人群中看过它一次,但它不会记住你的脸。解决办法不用我说,让一个人看那里的座位,其他人去点菜。这样,别人再来的时候,你就可以自信地说“对不起,我已经坐了这个位子”。再次相信聪明的你已经猜到我要说什么了,没错,就是(互斥)锁。回到程序中,如果公共区域(堆内存)的数据要被多线程操作,为了保证数据的安全性(或者说一致性),需要在数据旁边放一把锁。要操作数据,首先要获取锁。假设一个线程来数据看一看,发现锁是空闲的,没有人持有。于是它拿到了锁,然后开始操作数据。干了一会,累了,就去休息了。这时又来了一个线程,发现锁被别人持有了。按照规定,它无法操作数据,因为它无法获得锁。当然,它可以选择等待,也可以选择放弃做其他事情。第一个线程之所以敢大胆去休眠,是因为它手里拿着一把锁,其他线程不可能去操作数据。当它回来继续操作数据时,就可以释放锁了。锁再次回到空闲状态,其他线程就可以抢到锁了。谁先抢到锁,谁就对数据进行操作。classClassAssistant{doubletotalScore=60;finalLocklock=newLock();voidaddScore(doublescore){lock.obtain();totalScore+=score;lock.release();}voidsubScore(doublescore){lock.obtain();totalScore-=score;lock.release();}}假设一个班级的初始分数是60分,从这个班级中选出10名学生同时参与10个不同的答题程序,每名学生答对得5分正确,每答错扣5分。争取5分。既然是10个同学一起做,这肯定是并发的情况。因此,加分和减分这两个方法是并发调用的,共同对总分进行操作。为了保证数据的一致性,需要在每次操作前获取锁,操作完成后释放锁。相信这个世界充满爱,即使你受伤了,回到第一个例子,如果你把一万块钱扔在地上,你会丢掉吗?这取决于实际情况。如果你身处人来人往的城市,你可以说会迷路。要是扔在无人区的地上,可以说是绝对不会扔的。可以看出,他们都把东西放在公共区域不受保护,但结果却大相径庭。由此可见,安全问题还与公共区域的环境状况有关。比如我把数据放在public区的堆内存中,但是永远只有一个线程,也就是单线程模型,那么数据肯定是安全的。再者,如果两个线程操作同一个数据,200个线程操作同一个数据,这个数据的安全概率是完全不同的。可以肯定的是,线程越多,数据不安全的概率越大,线程越少,数据不安全的概率越低。举个极端的例子,就是只有1个线程,不安全概率为0,说明是安全的。可能大家又猜到了我想表达的意思,没错,就是CAS。可能大家认为,既然锁可以解决问题,那么就可以使用锁。为什么还有另一个CAS?那是因为锁的获取和释放都需要花费一定的金钱。如果线程数特别少,可能根本就没有其他线程来操作数据。这时候你还要去获取和释放锁,可以说是一种浪费。针对这种“地广人稀”的情况,专门提出了一种称为CAS(CompareAndSwap)的方法。即并发量小的时候,数据被误修改的概率很低,但是有这种可能,这时候就用到了CAS。如果一个线程操作数据,它已经完成了一半,累了,想休息一下。(看来今天的线程体质不是很好)。于是它记录下当前数据的状态(也就是数据的值),然后回家睡觉。起床后打算继续工作,但又担心数据可能被修改了,就把睡前保存的数据状态拿出来和现在的数据状态对比一下。如果相同,说明睡眠期间数据没有被修改。如果已经改了(当然也可能先改成别的,再改回来,这是ABA的问题),那就继续干。如果不一样,说明数据被修改了,之前的操作其实都是白费的,干脆放弃,从头开始重新处理。因此,CAS方式适用于并发量不高的情况,即数据被意外修改的可能性较小的情况。如果并发量高,你的数据肯定会被修改,每次都得放弃,然后从头再来。这样成本会更高,所以还是直接锁上比较好。这里我再解释一下ABA问题。如果睡觉前数据是5,醒来后数据还是5,则不能确定数据没有被修改。也许数据先修改为8,然后又改回5,只是你不知道而已。对于这个问题,其实很容易解决,只需要加一个版本号字段,规定只要修改数据,版本号就必须加1。这样数据为5,版本号睡觉前为0,醒来后数据为5版本号为0,说明数据没有被修改。如果数据是5,版本号是2,说明数据改了两次,先改成other,再改回5。再一次,相信聪明的你已经发现这里的CAS了其实就是乐观锁,而之前方案中的获取锁和释放锁其实就是悲观锁。乐观锁采取乐观的态度,假设我的数据不会被意外修改。如果修改了,就放弃,从头再来。悲观锁采取悲观的态度,即假设我的数据会被不小心修改,那么就可以直接加锁。