Java游戲服務(wù)器入門10 - 攻擊消息的問(wèn)題分析

上一篇文章的最后我們提到了攻擊消息的處理還是有些問(wèn)題,其實(shí)應(yīng)該還是比較嚴(yán)重的問(wèn)題,我們來(lái)演示一下。
在攻擊處理器:UserAttkCmdHandler中添加一條日志打印并重啟服務(wù)器

   if(null == targetUser){
   //如果沒(méi)打到人,也推送一下攻擊,這樣客戶端可以顯示攻擊動(dòng)作,否則沒(méi)有,影響體驗(yàn)
   broadcastAttrResult(attkUserId,-1);
   return;
   }
    //增加日志打印
   LOGGER.info("當(dāng)前線程 = {}",Thread.currentThread().getName());

   final int dmgPoint = 10;
   targetUser.currHp = targetUser.currHp - dmgPoint;

然后分別開(kāi)啟三個(gè)客戶端userId分別為1,2,3,測(cè)試地址:http://cdn0001.afrxvk.cn/hero_story/demo/step020/index.html?serverAddr=127.0.0.1:12345&userId=1,再分別使用角色1和角色2攻擊角色3,觀察日志打印

[INFO] UserAttkCmdHandler.handle --> 當(dāng)前線程 = nioEventLoopGroup-3-5
[21:18:05,794] [INFO] GameMsgHandler.channelRead0 --> 收到客戶端消息, msgClzz=com.tk.tinygame.herostory.msg.GameMsgProtocol$UserAttkCmd,msgBody = targetUserId: 3

com.tk.tinygame.herostory.cmdhandler.UserAttkCmdHandler@2232bd6b
[21:18:05,795] [INFO] UserAttkCmdHandler.handle --> 當(dāng)前線程 = nioEventLoopGroup-3-6

可以發(fā)現(xiàn)一個(gè)問(wèn)題,兩個(gè)攻擊消息的處理是在兩個(gè)線程內(nèi)進(jìn)行的,那么問(wèn)題就很明顯了,多線程操作會(huì)帶來(lái)問(wèn)題。

1.模擬錯(cuò)誤

如果有同學(xué)不太明白這種操作帶來(lái)的問(wèn)題,那么我為了模擬這種問(wèn)題編寫了測(cè)試代碼
在test中新建TestUser,和原程序無(wú)關(guān)哈

/**
 * 測(cè)試用戶
 */
public class TestUser {
    /**
     * 當(dāng)前血量
     */
    public int currHp;

    /**
     * 減血
     *
     * @param val
     */
    synchronized public void subtractHp(int val) {
        if (val <= 0) {
            return;
        }

        this.currHp = this.currHp - val;
    }

    /**
     * 攻擊
     *
     * @param targetUser
     */
    public void attkUser(TestUser targetUser) {
        if (null == targetUser) {
            return;
        }

        synchronized (this) {
            final int dmgPoint = 10;
            targetUser.subtractHp(dmgPoint);
        }
    }
}

創(chuàng)建測(cè)試類MultiThreadTest
首先測(cè)試一下,用兩個(gè)線程對(duì)同一個(gè)數(shù)值進(jìn)行修改,和我們目前項(xiàng)目中的思想類似,當(dāng)currHp和我們的預(yù)期不一致時(shí),會(huì)拋出異常

 /**
     * 兩條線程修改同一數(shù)值
     */
    private void test1() {
        TestUser newUser = new TestUser();
        newUser.currHp = 100;

        Thread t0 = new Thread(() -> { newUser.currHp = newUser.currHp - 1; });
        Thread t1 = new Thread(() -> { newUser.currHp = newUser.currHp - 1; });

        t0.start();
        t1.start();

        try {
            t0.join();
            t1.join();
        } catch (Exception ex) {
            // 打印錯(cuò)誤日志
            ex.printStackTrace();
        }

        if (newUser.currHp != 98) {
            throw new RuntimeException("當(dāng)前血量錯(cuò)誤, currHp = " + newUser.currHp);
        } else {
            System.out.println("當(dāng)前血量正確");
        }
    }

測(cè)試:

 static public void main(String[] argvArray) {
        for (int i = 0; i < 10000; i++) {
            System.out.println("第 " + i + "次測(cè)試");
            (new MultiThreadTest()).test1();
        }
    }

當(dāng)多執(zhí)行幾次后,會(huì)發(fā)現(xiàn)是有報(bào)錯(cuò)出現(xiàn)的,這種情況如果在血量上可能還可以接受,但是如果在用戶充值或者消費(fèi)上出現(xiàn)問(wèn)題呢?


報(bào)錯(cuò)演示
2.問(wèn)題解決方案

當(dāng)然我們第一想法就是使用synchronized關(guān)鍵字加鎖實(shí)現(xiàn),我也寫了對(duì)應(yīng)的代碼

 /**
     * 利用 synchronized 同步數(shù)據(jù)
     */
    private void test2() {
        TestUser newUser = new TestUser();
        newUser.currHp = 100;

        Thread t0 = new Thread(() -> { newUser.subtractHp(1); });
        Thread t1 = new Thread(() -> { newUser.subtractHp(1); });

        t0.start();
        t1.start();

        try {
            t0.join();
            t1.join();
        } catch (Exception ex) {
            // 打印錯(cuò)誤日志
            ex.printStackTrace();
        }

        if (newUser.currHp != 98) {
            throw new RuntimeException("當(dāng)前血量錯(cuò)誤, currHp = " + newUser.currHp);
        } else {
            System.out.println("當(dāng)前血量正確");
        }
    }

這樣我們就發(fā)現(xiàn),減血的代碼是正確的,并沒(méi)有報(bào)錯(cuò),但是考慮另一種情況就是我們?cè)诠魰r(shí),角色1在攻擊角色2的同時(shí),角色2也可以攻擊角色1,那就是另一種代碼的實(shí)現(xiàn)了

 /**
     * 死鎖
     */
    private void test3() {
        TestUser user1 = new TestUser();
        user1.currHp = 100;
        TestUser user2 = new TestUser();
        user2.currHp = 100;

        Thread t0 = new Thread(() -> { user1.attkUser(user2); });
        Thread t1 = new Thread(() -> { user2.attkUser(user1); });

        t0.start();
        t1.start();

        try {
            t0.join();
            t1.join();
        } catch (Exception ex) {
            // 打印錯(cuò)誤日志
            ex.printStackTrace();
        }
    }

錯(cuò)誤演示

這是我截取的打印信息,當(dāng)我們使用test03時(shí),卡在了第0次測(cè)試就不在繼續(xù)了,那我們查看一下信息:
1.在控制臺(tái)輸入:jps
錯(cuò)誤查看

2.在控制臺(tái)輸入:jstack + 進(jìn)程編號(hào)
錯(cuò)誤查看

3.查看錯(cuò)誤信息:
可以簡(jiǎn)單的看出:線程在等待其他線程釋放資源,其實(shí)可以很明顯的看出是死鎖的問(wèn)題
錯(cuò)誤信息

4.錯(cuò)誤分析及變成思路:
看到這里我們發(fā)現(xiàn)了使用synchronized好像解決不了問(wèn)題,首先他的鎖比較重,會(huì)影響速度,其實(shí)這是一個(gè)并不很重要的問(wèn)題,因?yàn)榻鉀Q速度問(wèn)題遠(yuǎn)遠(yuǎn)比解決線程問(wèn)題容易的多,主要還是因?yàn)樗麜?huì)帶來(lái)死鎖的問(wèn)題,這個(gè)問(wèn)題就是很大的問(wèn)題了(我們這里模擬了)

當(dāng)然有的同學(xué)會(huì)說(shuō)我們可以使用CAS的一些類,但是這里我們只做一個(gè)簡(jiǎn)單的計(jì)數(shù),這種CAS的方式對(duì)我們來(lái)說(shuō)又太重了。

解決方案:我們可以參考一下redis的做法,那就是我們可以把攻擊的處理放到一個(gè)線程執(zhí)行,使其串行化,可以很簡(jiǎn)單的解決這種多線程的問(wèn)題。那么有的同學(xué)會(huì)有疑惑,這樣會(huì)不會(huì)犧牲執(zhí)行速度,那答案是一定的。但是我們可以用最簡(jiǎn)單的辦法去解決這種線程的問(wèn)題,其次是使用這種方法,我們所有的計(jì)算都是基于內(nèi)存處理的,其實(shí)速度并不慢,速度方面也可以參考redis,redis執(zhí)行起來(lái)慢么?總結(jié)為一句話就是,處理線程問(wèn)題要遠(yuǎn)比處理速度問(wèn)題困難

那么在下一篇文章,我們會(huì)落地對(duì)我們分析的方案做出落地實(shí)現(xiàn)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容