我们在尝试使用HashMap中的给定键获取值时发现了NullPointerException。
以下是我将用来说明问题的示例代码。
public class Test {
private Map<String, Integer> employeeNameToAgeMap = new HashMap<String, Integer>();
public int getAge(String employeeName) {
if (!employeeNameToAgeMap.containsKey(employeeName)) {
int age = getAgeFromSomeCustomAPI(employeeName);
employeeNameToAgeMap.put(employeeName, age);
}
return employeeNameToAgeMap.get(employeeName);
}
}
在方法的最后一行获取NullPointerException,“return employeeNameToAgeMap.get(employeeName);”
正如我们所看到的,employeeNameToAgeMap不为null,并且调用者也没有将employeeName作为null传递(这是我们已经接受了调用代码本身)。
这个方法将从不同的线程以非常快的速度调用(来自某些计划任务,计划每100毫秒运行一次)
这个NullPointerException的原因似乎是给定员工的值(年龄)为空,但事实并非如此,因为保证自定义API方法(getAgeFromSomeCustomAPI())返回某个年龄给定员工,即使它返回null,那么异常堆栈跟踪应该显示日志中的对应行而不是最后一行。
我唯一的假设是,当一个线程T1试图填充该缓存时,T2出现了,由于某种原因,它能够发现缓存已经拥有了employeeName,但是当它试图获得年龄时,它就抛出了一个NPE。但是我并不是100%确信当put()操作正在进行给定的键和值时,相同键的containsKey()返回true。
我知道需要增强此代码以解决同步问题(通过使用ConcurrentHashMap或锁定),但期待知道此问题的真正原因。
我真的很感激帮助。
答案 0 :(得分:4)
我并非100%确信当put()操作正在进行给定的键和值时,相同键的containsKey()返回true。
你是正确的 - 没有任何同步,不能保证在一个线程中调用put()会导致另一个线程为containsKey()返回true。即使对put()的调用已经完成,也是如此。
Java内存模型允许重新排序线程的内存读/写。这通常称为无序执行。结果是任何给定线程看到的Map的内部状态可能不一致,这可能导致数据损坏,崩溃和无限循环。
对于您的简单示例,看起来您可以简单地将HashMap
替换为ConcurrentHashMap
,但如果没有看到您的其他程序,就很难确定这是否正确
我建议阅读Brian Goetz的 Java Concurrency in Practice ,以便更好地理解Java内存模型。
您可能也对此博文A Beautiful Race Condition感兴趣,其中显示了为什么在没有同步的线程之间共享HashMap会导致意外行为。
修改:可能值得您关注HashMap的JavaDoc的这一部分:
如果多个线程同时访问哈希映射,并且至少有一个线程在结构上修改了映射,则必须在外部进行同步。 (结构修改是添加或删除一个或多个映射的任何操作;仅更改与实例已包含的键关联的值不是结构修改。)这通常通过同步自然封装映射的某个对象来完成。 。如果不存在这样的对象,那么地图应该被&#34;包裹&#34;使用Collections.synchronizedMap方法。这最好在创建时完成,以防止意外地不同步访问地图:
Map m = Collections.synchronizedMap(new HashMap(...));
答案 1 :(得分:3)
我相信您所遇到的是在您致电
期间对HashMap的重复return employeeNameToAgeMap.get(employeeName);
有人可以相信,如果HashMap#containsKey(key)返回true,那么应该保证,调用HashMap#get(key)也应该返回一个有效值,只要该密钥没有从HashMap中删除。这可以通过以下事实来论证:如果密钥对应于有效值,HashMap#containsKey(key)确实会检查:
public boolean containsKey(Object key) {
return getEntry(key) != null;
}
但这是一种致命的误解。 HashMap#containsKey(key)只能保证密钥在调用之前已经与某个值相关联。但是,如果多个线程正在访问地图,则不能保证HashMap #get(key)也会返回相应的值。造成这种差异的原因是,其他线程访问HashMap#put(key,value)与任何键值对,可能会强制重新散列HashMap,从而导致重新创建内部哈希表。如果在调用HashMap#get(key)期间发生了这样的重组,那么即使你的HashMap在调用HashMap#containsKey(key)时返回true,HashMap#get(key)也可能返回null。
如果您只想避免NullPointerException,可以这样做:
public class Test {
private Map<String, Integer> employeeNameToAgeMap = new HashMap<String, Integer>();
public int getAge(String employeeName) {
final Integer age = employeeNameToAgeMap.get(employeeName);
if (age == null) {
age = getAgeFromSomeCustomAPI(employeeName);
employeeNameToAgeMap.put(employeeName, age);
}
return (int)age;
}
}
这当然会不使您的代码线程保存,但您将不再获得您现在遇到的NullPointerException。
答案 2 :(得分:2)
这是一个很好的例子,说明为什么不被认为是线程安全的东西不应该在线程环境中使用,即使你无法想象可能出错的。这里的问题是缺乏想象力。
这里有两件事可能出错:
在Java执行重新排序是你的敌人。
如同关键字定义volatile的代码执行的顺序可以被重新排列由JVM不管出于什么目的,只要结果是在单线程环境中 。因此,可以在实际设置值之前首先添加键值对,从而导致同时get
调用返回中间null
值。
哈希映射实现中的一些机制正在使用惰性集机制,因为在特定实现上结果更快。虽然到目前为止我看到的代码中的情况并非如此,但它告诉您,您不应该像编写代码一样期望。
要学习的一课:坚持以文档和唯一的文档,因为其他一切都没有确定,因此可能会被更改或已经对你所期望太大区别
答案 3 :(得分:-1)
employeeNameToAgeMap.get(employeeName);
会返回Integer
。如果Integer
为空,则从您的方法返回int
所需的int
自动取消装箱会抛出NPE。
所以你应该写一些类似的东西:
Integer result = employeeNameToAgeMap.get(employeeName);
return result == null ? -1 : result;
或者,您可以抛出异常,例如EmployeeNotFoundException
。
或者您也可以返回一个'Integer`文档,返回的值可能为null,并让客户端处理null case。