大家好,我是程序员牛肉。
不知道大家有没有写过"黑马点评"这个项目,这个项目中有一个功能模块是用户秒杀优惠卷。在这个过程中需要保证一个用户只能抢到一单。在这个过程中我们就需要对用户id进行加锁。
这里加锁使用的对象是:
scss
userId.toString().intern()
我们来解释一下为什么要这么写:
首先因为Synchronized锁的是对象。因此我们需要将userId使用toString转为一个字符串对象。但是toString每一次都会创建一个新的字符串对象:
因此如果单纯只是锁用户id使用toString的对象的话,实际上是没有办法保证一人一单的。因为即使两个userid相同,使用toString之后也会得到两个对象。
基于这一点,黑马点评中给出的解决方案是使用intern方法将这个字符串常量放入常量池中。避免了两个字符串内容相同,但不是同一个对象的bug。
[字符串常量池是一个特殊的内存区域,用于存储字符串字面量和通过 intern() 方法加入的字符串。当一个字符串调用 intern() 方法时,如果常量池中已经存在一个相等的字符串,则返回常量池中该字符串的引用;如果不存在,则将该字符串添加到常量池中并返回其引用。]
但真的这样就可以了吗?
让我们往真实的业务上靠一靠来模拟一下这个场景:随着大量的用户秒杀优惠卷,越来越多的userId会被加入到字符串常量池中。
但问题是:字符串常量池的大小也是有限的,这玩意不是一个异次元空间能让你不停的塞变量。
那垃圾回收机制能够对字符串常量池中的不再被使用的字符串进行清理吗?
如果你是一位深耕JVM的八股战士的话,就应该背过哪些节点是GC Roots:
这一看直接天塌了。不是哥们,你也没说把一个字符串变量放到字符串常量池中就变成根节点了啊。
为了防止有些同学忘记什么是GC Roots,我们也来顺手讲一下:
[在 Java 的垃圾回收机制中,GC Roots(Garbage Collection Roots)是垃圾回收器用来追踪和识别活动对象的起始点。任何从 GC Roots 可达的对象都被视为存活的,不会被垃圾回收。GC Roots 是垃圾收集算法(特别是标记-清除算法)用来判断对象是否可以被回收的基础。]
也就是说我们如果不断的把字符串放到常量池之后,他就会成为一个根节点,而根节点是不会被垃圾回收器回收掉的。
而JDK7之后这个字符串常量池是在堆中的。因此过度使用 intern() 可能导致堆内存耗尽,从而引发内存溢出(OutOfMemoryError)。
因为你的代码bug导致oom直接给服务干瘫痪之后,你也基本就可以再就业了。
但我们又确实有以字符串作为锁对象的这个需求。那我们要怎么解决这个问题呢?
我们都能想到这个问题,就一定要相信大概率情况下业内已经有解决方法了。这份解决方案来自谷歌的guava工具类下的interner。
Guava 的 Interner 是一个用于管理对象实例唯一性的工具接口。它的主要作用是确保对于相同内容的对象,只保留一个共享的实例,从而减少内存使用和提高性能。Interner 接口以及相关的实现类提供了一种高效的方式来管理和共享相同内容的对象。
我们可以这样理解:之前我们基于将字符串纳入字符串常量区的机制来避免加锁失败的这种机制,本质上是搞了一个公共区域来存放已经创建好的字符串,如果这个内容你之前创建过了,那么就直接复用公共区域中的这个字符串。以此来保证创建对象的唯一性。
既然这个公共区域我们的垃圾回收机制没有办法进行监管,那我们能不能把这个公共区域就放在java的代码层?直接在代码层就来保证对象实例的唯一性。
当你能够想到这里,其实你也就明白了guava的Interner运行机制:提供一个hashmap来把这个"公共区域"直接放到java代码层。
Guava 提供了两种 Interner 的实现,主要包括:
Interners.newStrongInterner():
-
创建一个使用强引用的 Interner。
-
所有的对象都被强引用,这意味着只要 Interner 存在,对象就不会被垃圾回收。
Interners.newWeakInterner():
-
创建一个使用弱引用的 Interner。
-
对象被弱引用,这允许垃圾回收器回收不再被其他强引用持有的对象,从而避免内存泄漏。
我们来看一看newWeakInterner是在什么,当我们尝试使用默认方法构造的时候,会进入这个方法。
这个构造函数内部链式调用了很多的方法,我们一个一个看:
1.weakKeys:
这个代码的逻辑比较简单一点:将我们即将创建出来的这个map的key设置为弱引用。当键不再有任何强引用指向它时,垃圾回收器可以回收键该键。
此时好奇的同学可能会想:key被回收了之后,key对应的value是怎么处理的?先不考虑这个点,我后面也会讲到这个的。
下一步调用的是keyEquivalence方法
在这个方法中设置了这个map中key的等价策略。用大白话来讲就是在这里我们定义了两个key在什么情况下才算是相等的。
在上面的链式调用中,我们传递到的参数是:
Equivalence.equals() 是 Guava 提供的一个基于标准 equals() 和 hashCode() 方法的等价性策略。也就是说在这个map中,我们认为两个key的equals和hashcode相等的情况下,我们就认为这两个key是相等的。
也就是说:在这个链式调用中,我们创建了一个key是弱引用的map。在比较key是否相等的时候采用的是equasl和hashcode进行比较。
基于这种性质,其实我们就可以先创建出来一个weakInterner。然后调用这个类中的intern方法来确保当前key是唯一的,不会被重复创建:
java
synchronized (interner.intern(key)) {
//待运行代码
}
让我们来详细的看一看这个intern方法:
这个逻辑也很清晰:其实就是现在map中尝试寻找当前key,如果找到的话就返回一个实例,如果找不到的话就将其作为key插入到map中。而key对应的value是一个枚举类:
这其实是一个很巧妙的思想,我们想一想:其实我们只需要key的值。而对于value我们又不能不添加值。
那么最优解其实就是让这个value是一个全局唯一变量。所有key所对应的value本质上都是一个value。
说白了就是在value上搞一个单例模式。而枚举类本身就是单例模式的最简短的实现方案。基于这种思想我们又在一定程度上节省了内存的使用率。
所以其实interner的逻辑还是很简单的,就是搞一个weakmap来让你把已经有的String全部放进去。之后每一次加锁的时候都会尝试到这个weakmap中是否这个String已经存在了。
如果没存在就创建一个,如果已经存在了,就把存在的这个key返回给synchronized来锁这个对象就可以了。
那今天关于guava包下的interner就介绍到这里了,相信通过我的介绍,你已经大致知道了直接给String 使用intern方法的弊端。希望我的文章可以帮到你。
对于guava包下的interner类你还有什么想说的吗?欢迎在评论区留言。
关注我,带你了解更多技术干货。