Java程序员狂写代码防出错,效率降低背后有说法

晓风拂柳岸 4天前 阅读数 0 #推荐
嘿,有没有觉得挺奇怪的,咱们天天说要速度,要效率,结果到头来,搞个程序,为了防止数据出错,还得加锁,慢悠悠地排队执行。这就像啥?就像你辛辛苦苦攒钱买了辆跑车,结果市区限速,只能在环路上龟速爬行。

程序员的世界里,锁这玩意儿,就跟交通规则一样,虽然限制了自由,但保证了大家的安全。想想看,十几个线程同时往一个账户里加钱,你不加锁,可能最后算出来的总额,比你实际存的还少,这玩笑可开大了。这背后的原因,说白了就是“原子性”没保证。本来“余额=余额+100”应该是个整体,一口气完成,结果被拆成好几步,这边刚读了余额,还没来得及加,那边又来个线程把余额改了,那这加了个寂寞啊!再说说可见性。假设每个线程都偷偷摸摸地在自己的小本本(缓存)上记账,最后汇总的时候,发现大家的账对不上,那可就乱套了。

所以,锁的作用就是让大家强制性地去公共账本(主内存)上查账,保证看到的是最新数据。还有有序性,这更玄乎,有时候CPU为了更快,会把指令的顺序颠倒一下,结果本来好好的程序,突然就跑偏了。锁,某种程度上,也肩负着“维持秩序”的重任。单机锁,就是在一个电脑里管用的锁,像是synchronized和ReentrantLock,都是Java自带的。synchronized就像个老管家,原始但可靠,直接锁住对象或者方法。ReentrantLock呢,就更灵活,你可以定制锁的各种属性,比如是不是公平锁,能不能超时等待。打个比方,synchronized就像是家里的一把普通锁,ReentrantLock就像是高科技的指纹锁,功能更多。

不过,synchronized也不是啥时候都好用。它有个升级过程,从无锁到偏向锁,再到轻量级锁,最后变成重量级锁。一旦变成重量级锁,那效率就直线下降,因为要涉及到用户态和内核态的切换,这可是个耗时的大工程。ReentrantLock有个好用的地方,就是可以设置超时时间。比如你用`tryLock(3, TimeUnit.SECONDS)`,意思就是说,如果3秒钟还拿不到锁,那我就不等了,直接放弃。这在一些场景下很有用,可以避免程序一直卡死在那里。还有一种叫CAS的“无锁编程”,听起来很高级。

它就像个“比大小”的游戏,先看看现在的余额是不是我预期的值,如果是,就直接修改,如果不是,说明别人已经改过了,那我就重新来一遍。这种方式避免了锁的开销,但有个ABA问题,就是说,余额虽然变回了原来的值,但实际上已经被别人改动过了。解决办法也简单,加个版本号,每次修改都更新版本号。如果并发量实在太大,单机锁就有点力不从心了。这时候,就需要分布式锁出马了。分布式锁,顾名思义,就是能在多个服务器之间起作用的锁。

常见的实现方式有Redis和Zookeeper。Redis分布式锁,用Redisson这个框架实现起来很简单。它利用Redis的原子性操作,加上Lua脚本,保证加锁的原子性。而且还有个“看门狗”机制,自动续期,防止锁过期。但Redis有个缺点,就是如果主节点挂了,可能会出现锁丢失的情况。Zookeeper分布式锁,就更可靠一些。它利用Zookeeper的临时有序节点,保证锁的唯一性。

当一个节点释放锁的时候,Zookeeper会通知下一个节点,让它获得锁。但Zookeeper的性能相对Redis要差一些,因为涉及到集群协调。选哪个呢?这得看你的需求。如果对性能要求高,可以选Redis,但要做好应对锁丢失的准备。如果对一致性要求高,那就选Zookeeper,牺牲一些性能换取可靠性。当然,锁也不是万能的。

有时候,不用锁反而更好。比如ThreadLocal,每个线程都有一份自己的数据,互不干扰。还有不变性模式,用final修饰对象,保证对象创建后就不能被修改。最后,灵魂拷问一下:你的锁真的安全吗?有没有可能因为String常量池的问题,导致不同的业务共享了同一把锁?有没有可能因为锁对象被修改,导致锁失效?还有,锁异常未释放,导致程序卡死?Brian Goetz说过:“并发BUG就像薛定谔的猫,你永远不知道何时会跳出来挠你。”所以,写并发程序,一定要小心谨慎,多做测试,才能避免线上血案。记住,锁的选择没有绝对的对错,只有适合不适合。就像穿鞋一样,合脚才是最重要的。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

晓风拂柳岸

晓风拂柳岸

晓风拂柳岸