记一次对synchronized的思考过程

遇到的问题

最近在维护公司的一个老项目,这个项目是一个完完全全的单体应用。有一个用户签到加积分的接口,为了防止用户多次请求而导致数据库插入多条记录的问题。简而言之就是需要保证一个新增接口的幂等性问题。这里就不赘述其他的解决方案了,比如说可以在数据库做唯一索引token机制,单体应用可以用synchronized或者JDK自带的ReentrantLock等等。

问题探讨与结论

最初我想到的是,直接在方法上面加一个synchronized关键字,来修饰这个方法。这样毫无疑问是最简单的一种.

public  synchronized Rsp addUserIntegral(AddUserIntegralReq req){
    // 1、先查询该用户用户今日是否签到了
    // 2、如果没有签到,添加积分
}
复制代码

但是这会存在一个问题:修饰方法的synchronized,它会持有一把当前对象的锁(this);同时我们的应用是存在于Spring中的,那么当前对象默认就是一个单例对象,这会导致张三添加积分时李四不能添加!这样并发的性能大大降低。需要另外一种方式,每次请求都不是同一个对象来作为锁。

我又想到了传入一个HttpServletRequest对象作为锁对象(每次请求天然是一个不同对象),结论证明不能这样做。因为这样加锁根本就没有互斥的效果。

睡个午觉起来,很容易想到使用 AddUserIntegralReq req方法参数来作为锁对象,每个用户的请求不一样同个用户的请求一样。这样就可以做到针对不同用户做到互斥。下面是AddUserIntegralReq的类结构

public class AddUserIntegralReq{
    private String userAccount; //这个是唯一主键
    private Integer points;
    //getter setter ...
}
复制代码

我尝试着重新这个类的hashCode和equals方法,将这个对象作为锁对象,下面看看测试结果:

public class Test {

    public void test(Demo demo){
        synchronized (demo){
            if (demo.id.equals("1")){
                System.out.println(demo.getId());
                while(true);
            }else{
                System.out.println(demo.getId());
            }

        }
    }
    static class Demo{
        private String id;
        private Integer points;
        //getter setter ...
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            Demo demo = (Demo) o;
            return Objects.equals(id, demo.id) && Objects.equals(points, demo.points);
        }
        @Override
        public int hashCode() {
            return Objects.hash(id, points);
        }
    }
}
复制代码

测试代码:

    public static void main(String[] args) throws InterruptedException {
        Test test = new Test();
        //线程1
        new Thread(new Runnable() {
            @Override
            public void run() {
                Demo demo = new Demo();
                demo.setId("1");
                demo.setPoints(10);
                test.test(demo);
            }
        }).start();
        Thread.sleep(1000);
       //线程2
        new Thread(new Runnable() {
            @Override
            public void run() {
                Demo demo = new Demo();
                demo.setId("1");
                demo.setPoints(10);
                test.test(demo);
            }
        }).start();
    }
复制代码

创建一个“相同对象”,根据我们假设,如果synchronized采用的是equals去判断一个对象是否相同,那么测试代码打印出来应该就只有一 “1”,但是结果两个线程都打印了 “1”,说明了synchronized持有的这个Demo对象并没有完成我想要的互斥效果。推出了synchronized是采用的 == 地址比较,持有的锁对象是否是同一个。

虽然结论得到了synchronized是采用的 == 地址比较,持有的锁对象是否是同一个,但是上面遇到的实际业务情况又该怎么解决呢?我想到了采用 userAccount作为锁对象,因为他是String类型,如果不是new 出来的String对象,它是存放在常量池中的,可以有不同的引用持有。我当时大呼秒啊,于是马上调整了代码

    public void test(Demo demo){
        synchronized (demo.getId()){
            if (demo.id.equals("1")){
                System.out.println(demo.getId());
                while(true);
            }else{
                System.out.println(demo.getId());
            }

        }
    }
复制代码

测试代码不变

    public static void main(String[] args) throws InterruptedException {
        Test test = new Test();
        //线程1
        new Thread(new Runnable() {
            @Override
            public void run() {
                Demo demo = new Demo();
                demo.setId("1");
                demo.setPoints(10);
                test.test(demo);
            }
        }).start();
        Thread.sleep(1000);
       //线程2
        new Thread(new Runnable() {
            @Override
            public void run() {
                Demo demo = new Demo();
                demo.setId("1");
                demo.setPoints(10);
                test.test(demo);
            }
        }).start();
    }
复制代码

这次的结果完美符合预期,只是线程1打印出了 “1”

于是我将测试写的Demo的加锁方式腾挪到正式代码中去,偶买噶~!!!翻车了,下面是我的腾挪过去之后的样子

public  Rsp addUserIntegral(AddUserIntegralReq req){
    synchronized(req.getUserAccount()){
         // 1、查询该用户今天是否签到了
         // 2、如果没有签到添加积分
    }
}
复制代码

纳尼!!! req.getUserAccount() 两次请求getUserAccount()出来的相同对象不是同一个了!这是为何呢?

突然灵光一闪!Spring MVC的问题!!两次请求在Controller中使用@RequestBody注解将前端传入的json字符串转换为对象,破案了破案了,我大呼尼玛。大胆猜测:JackSon(Spring MVC默认的)将json字符串转换为对象之后,该对象的所有属性的值全部在Java堆上,也就是说给字符串赋值的时候采用的是类似于 demo.setId(new String(“1”))这种方式,导致两次获取userAccount对象都是堆上的对象并不是常量池中的同一个对象。

Demo demo = new Demo();
demo.setId("1");
demo.setPoints(10);
String s = JSONUtil.toJsonStr(demo);
Demo demo1 = JSONUtil.toBean(s, Demo.class);
Demo demo2 = JSONUtil.toBean(s, Demo.class);
System.out.println(demo1.getId() == demo2.getId());
复制代码

输出结果 false
案是破了,但是最开始的任务可没有完成呀。最后不得不采用一种很蹩脚的方式来实现(如果看到这里你认为有比我下面更好的方式,麻烦告诉下)


private static final Map<String,Object> lockMap = new ConcurrentHashMap<>();
public  Rsp addUserIntegral(AddUserIntegralReq req){
    String userAccount = req.getUserAccount();
    if (!lockMap.contains(userAccount)){
        lockMap.put(userAccount,new Object());
    }
    try{
      synchronized(lockMap.get(userAccount)){
         // 业务逻辑
      }
    }finally{
        //防止内存溢出
        lockMap.remove(userAccount);
    }
  
}
复制代码

这种方式相当于是做了一次唯一主键到唯一对象的映射。

PS:如果各位同学有更好的方法,请告知我

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享