hello,大家好,我是一名Java开发练习生,在前段时间的工作中遇见了一个偶发性的用户切换登录闪退的问题,话不多说,让我直接来复现一下这个问题。
问题复现

如图所示,用户登录成功后,立即触发登录过期策略,并将页面重定向至登录页面
问题原因排查
前端代码排查
首先排查前端代码,先看一下登录过期的提示信息是在什么场景下触发的,然后发现是后端响应了登录过期报文,导致前端将页面重定向至登录页。

后端代码排查
既然是后端导致的页面重定向,那么现在只需要知道后端是在什么情况下响应了登录过期报文。查看后端日志,发现了这么一段报错提示:
这个错误表示有线程正在尝试调用一个已经销毁的session对象的setAttribute方法,于是抛出了这个异常。 抛出异常的代码片段如下:
java
if (session == null || !this.isSessionInited(session, ar.getAccount())) {
try {
session = httpreq.getSession(true);
this.initSession(httpreq, httpresp, session, ar.getAccount());//内部对session进行处理,如调用setAttribute方法等
} catch (Exception e) {
this.log.error("[SSO-Filter] Init session failed!", e);
this.jumpLogin(httpreq, httpresp);//重定向至登录页
return;
}
}
java
protected boolean isSessionInited(HttpSession session, String loginUserId) {
RuntimeContext curARC = (RuntimeContext)session.getAttribute("CurARC");
if (curARC == null) {
return false;
} else if (loginUserId == null) {
return false;
} else if (!loginUserId.equals(curARC.getUser().getLoginId()) && !loginUserId.equals(curARC.getUser().getUserID())) {
ARE.getLog(AWESSOFilter.class).info("USER CHANGE NEWUSER=[" + loginUserId + "] OLDUSER[" + curARC.getUser().getUserID() + "]");
this.clearSession(session); //内部调用session.invalidate();方法
return false;
} else {
return true;
}
}
通过添加日志,以及断点调试,发现出现这个异常的原因是线程竞争问题导致,如线程A和线程B的会话ID是一致的,因此线程A与线程B获取到的会话对象(session)是同一个,线程A在调用session对象的setAttribute方法时,线程B已经调用了invalidate方法销毁了session对象,因此抛出了非法状态异常。
解决方案
上面已经确定了导致问题的根本原因是两个线程同时操作一个session对象引起的并发问题,那么解决这个问题的思路就是让多个线程不在同一段时间内访问同一个session对象,即互斥访问。
用户粒度锁实现
我解决这个问题的方案就是通过同步代码块,实现多线程互斥执行出现问题的这段代码,锁对象则是通过用户id获取,维护一个HashMap,用户id为key,锁对象为value,同一个用户对应同一把锁,这样同一个用户下的多个会话线程会互斥执行这段代码,而不同的用户之间不会相互影响,即解决了问题,也能保证系统的效率。 下面直接给出代码实现:
java
private static final ConcurrentHashMap<String, Object> LOCK_MAP = new ConcurrentHashMap();//锁池对象
//用户级锁同步代码块实现
Object userLock = LOCK_MAP.computeIfAbsent(currentUserAccount, (k) -> new Object());
synchronized(userLock) {
try {
if (httpreq.getSession(false) == null) {
session = null;
}
if (session == null || !this.isSessionInited(session, ar.getAccount())) {
session = httpreq.getSession(true);
this.initSession(httpreq, httpresp, session, ar.getAccount());
}
} catch (Exception e) {
this.log.error("[SSO-Filter] Init session failed!", e);
this.jumpLogin(httpreq, httpresp);
return;
} finally {
LOCK_MAP.remove(currentUserAccount);//执行完后清楚锁池中的资源,防止map中数据膨胀导致内存溢出
}
}
通过上面这段代码其实已经能够解决我们所面临的问题了,但是这段代码存在一个bug,因为线程A执行完后会清除锁池中的锁对象(这里称为old lock),此时如果有一个线程B已经在阻塞队列中等待old lock的释放,线程后执行完代码后从锁池中清除了old lock的引用,此时另外一个线程C发现锁池中不存在该用户id对应的锁对象于是新new了一个锁对象,然后就会出现线程B和线程C同时进入同步代码块的现象,还是会出现线程竞争问题。 所以要彻底解决这个问题,我们需要自己定义一个类,该类中定义一个计数器,统计当前有多少个线程在占用或者等待这把锁,在释放之前先判断计数器的值是否为0,如果为0则从锁池中清除锁的引用,如果不为0则说明还有其他线程正在等待这把锁释放,则不应该清除锁的引用。