Java游戏编程不完全详解-1

前言

1991年,我第一次在DOS操作系统下玩“F-117A Stealth Fighter 2.0 ”游戏,这是一款像素级的模拟器游戏。
1.png

2.png

于是,我这辈子被种草游戏了--从此爱上了游戏,并且想写一款游戏。于是,我考大学时就报考了计算机系专业,因为别人告诉我大学里会学怎么编程啊、肯定也会编写游戏啊等等...

不过,呵呵,相信大家也知道这是一个谎言!我上了大学后发现别人告诉我的东西根本没有不存在,差一点被害得在毕业时都入不了IT行业,还谈什么游戏开发了!。结果搞了10年的EPR应用开发--因为得先填饱自己肚子实现了生存再说哦!

不过,写游戏的梦想一直存在我的心中,直到2011年我转行做了手机游戏的开发,本大黍是第一批使用Coco2d-x开源引擎做手机游戏开发的前辈--第一款是使用0.91版本开发的,直到现在使用的cocos2d-x 4.0版本!

Java游戏编程之多线程

对于游戏用来说,对游戏第一个的要求就是运行高效--运行一定要流畅,画面一定要美!为保证游戏运行高效和流畅,因此我们必须从游戏的技术选型上就必须要考虑,再加上现代硬件非常高效,操作系统都是多进程和多线程的,因此怎样利用多线程来让程序变得更加高效,这是一个必选题。

为什么使用Java编游戏?

Java 1.4版本以后,我们可使用Java平台来开发快速的、全屏幕的和硬件加速(显卡)的游戏!同时,使用Java意味着可以使用复杂的API来简化OOP编程、简化的多线程编程、自动的垃圾回收 ,以及良好的可移植性。除些之外,还有大量开源的库以及优雅、方便的IDE等来使用。

Java相对于C和C++就是它的速度问题,但是如果使用HotSpot VM和独立显卡之后,那么它的游戏运行速度就不是问题了。HotSpot技术是把游戏在运行时编译到本地码中去,加上强大的独立显卡,这时Java编写的游戏就不再会有运行速度的困扰。

什么是多线程?

如果把计算机处理器看成是一个熟练的侍者,而把用户看成是一个任务,那么每个任务都有自己的线程(Thread)。而一个处理器在现代操作中可以并发(concurrently)运行多个线程。比如,我们时常会从互联上下载电影时,还听着音乐的写着代码(^_^)…或者边聊QQ边写代码等。

现代的操作系统并发运行线程时,是把线程的任务分解成更小的块(单元)来处理的—这就叫做并发(concurrency)。一个线程只有一小块时间片来执行,然后该线程被悬空(pre-empted),以便让其它的线程运行,然后如此循环。如是下图:

image-20210325200519950.png

  • hread A--线程A
  • Thread B--线程B
  • Thread A Starts--线程A启动
  • THread B Starts--线程B启动

使用Java创建线程和使用线程

其实Java就是使用线程概念被设计的,所以我们会发现在Java使用线程工作是非常容易的事情,如果想创建并且启动一个新的线程,那么我们只需要创建一个Thread对象的实例,然后呼叫它的start()方法即可。

Thread myThread = new Thread();
myThread.start();

当然该代码没有做任何事情,因为JVM只是创建一个新的系统线程(system thread),然后启动了它,最后呼叫了该线程对象的run()方法,但是run方法没有做任何事情。

使用线程最便捷的方式是直接继承Thread类,然后重写run方法:

public class MyThread extends Thread{
    public void run(){
        System.out.println(“do something”);
    }
}

然后创建这个类的对象,然后启动它:

Thread myThread = new MyThread();
myThread.start();

现在我们两个线程在运行了:主线程和我们现在创建的子线程对象。
继承Thread类非常容易,但是大多数时候我们可能不希望书写一个新的类型就想启动一个线程。比如,我们希望继承另外一个类,但是又想把段代码作为一个线程来运行,那么这种情况下我们需要实现Runnable接口:

public class MyClass extends BaseDAO implements Runnable{
   public MyClass(){
    Thread thread = new Thread(this);
         thread.start();
   }
   //实现run方法
   public void run(){
         System.out.println(“Do something cool here!”);
   }
}

以上示例MyClass对象在构造方法启动了一个新的线程。

Thread类把Runnable对象作为它的构造方法的参数,而Runnable对象是在该线程被启动时执行。 有时候我们不想新创建一个类,但是又想封装一个线程,这时时候我们可以使用匿名内部类来实现:

new Thread(){
    public void run(){
         System.out.println(“Do something cool here”);
     }
}.start();

该示例代码非常简单,但是如果run方法的代码太长,那么它的可读性就非常差(解读:这种写法在现代叫做流式布局写法,非常的流行,但是在Java之初不是赞成的。这个Java之初的时间,我认为应该是Java在没有被卖给Oracle之前,它一直强调严谨的风格,因为我是JDK 1.4版本的程序员,曾经考过Sun公司的Java认证嘛!所以咱们是非常了解什么是最正宗的Java代码风格的。本人并不认为Java 5以后的版本是真正的Java了,它已经变得四不像了,特别是当Java 8引入了函数编程-lambda表达式之后。当然,这个仅代表本人的观点,不喜勿喷哈!)。

如果我们需要我们当前的线程等待另外一个线程运行完成,那么使用join()方法:

myThread.join();

该方法非常有用,它一般使用来让一个玩家退出我们的游戏,因为我们需要等待所有线程都运行完成,然后才能做复位的动作。 如果我们需要让线程休息一下,比如让一个线程暂停一会儿,那么使用sleep()方法:

myThread.sleep(1000);

这样做结果是让当前运行的线程睡觉一秒钟,但是睡觉不会CPU的时间—当然它不会做梦的。

线程同步

很好,现在我们可以使用多个线程来同时做一些非常cool的事情了,但是这并不表示万事大吉了。因为,如果出现多个线程访问相同的对象或者变量时,那么就会出现同步(Synchronization)的问题。

为什么产生同步?

让我们看一个迷宫游戏,任何线程都可以设置玩家的位置,任何一个线程都可以检查玩家是否还存在。假设,玩家处理位置是x = 0, y = 0.

image-20210326104920749.png

  • isAtExit()--判断玩家是否存在
  • setPosition()--设置玩家当前位置

以上代码在大多数情况是运行正常的,但是考虑到线程会被操作系统在任意时刻悬空,如果出现这种情况,有一个玩家从(1,0)移到(0,1)位置:

  1. 出现点对象的变量是playerX = 1和playerY = 0
  2. 线程A呼叫setPosition(0,1)
  3. 代码playerX = x被执行,现在playerX = 0
  4. 线程A被悬空让位给线程B
  5. 线程B呼叫isAtExit()方法
  6. 那么现在playerX = 0并且playerY = 0, 所以isAtExit返回true值!

在这种情况下,用户玩家被会告知Over了。为解决这个问题,我们必须保证setPosition方法和isAtExit方法不能同时被执行!

为保证线程同步,我们使用关键字synchronized来实现它,它可以保证一次只运行一个方法,下面是线程安全的代码:

public class Maze{
    private int playerX;
    private int playerY;
    public synchronized boolean isAtExit(){
        return (playerX == 0 && playerY == 0);
    }
    pubic synchronized void setPosition(int x, int y){
        playerX = x;
        playerY = y;
    }
}

当JVM执行同步方法时,它需要一个该对象的锁(lock)。一次只能从该对象中获取一把锁。当该方法被执行完成之后,该锁会被释放,否则会抛出异常。所以,当一个被同步的方法获取一把锁之后,其它的被同步的方法不能被运行,除非该锁被释放掉了。我们可以把这把锁想像成一个只有一个位置的公用卫生间门的锁,该卫生间一次只有一个人使用,只有当该人离开之后,该锁才是未被锁定的。
另外除了方法同步之外,对象也可以被同步。对象同步时,我们需要把任何一个对象看成锁(与方法同步的原理一样)。此时当前实例(this)就是一个锁。方法同步实际上是使用this关键实现对象同步的缩写。比如代码

public synchronized void setPosition(int x, int y){
    playerX = x;
    playerY = Y;
};

它与使用”this”关键字书写的功能一样:

public void setPosition(int x , int y){
    synchronized(this){
        playerX = x;
        playerY = y;
    }
}

对象同步当我们需要多个锁时是非常有用的。比如我们某些事情需要上锁,而不让”this”对象上锁,或者当我们不需要让整个方法被同步时。锁可以适用所有对象,包括数组,除了原始数据类型。如果我们需要创建自己的锁,那么只需要创建一个普通对象即可:

Object myLock = new Object();
…
synchronized (myLock){
    …
}

什么需要同步呢?任何时候只要两个或者以上的线程需要访问相同对象或者属性时,我们称这种情况叫做同步。那什么时候不使用同步呢?回答是,当我们同步我们的代码时,不要过度同步(oversynchronize)—不要同步太多的代码。因为结果会产生多线程的不必要的延迟,从而不会达到使用线程代码之后加快代码效率。比如一般使用不同步整个方法的形式来进行同步关键代码的操作。

public void myMethod(){
    synchronized(this){
        //下面是被同步的关键代码
    }
    //下面是其它线程安全的代码
}

另外,我们不必同步局部变量。因为局部变量是放栈里,而每个线程拥有自己的栈空间,所以它们不会产生同步问题!比如下面的方法如果使用局部变量,那么不需要被同步:

public int square(int n){
    int s = n * n;
    return s;
}

最后,不需要担心同步代码会被多个线程对象访问。如果我们只知道一些代码只被一个线程访问,那么我们不需要进行同步,因此,我们需要做好JavaDoc的注释,说明该方法是非线程安全的如果我们不知道哪个线程访问我们的代码,那么我们可以在控制台打印出该线程的名称:

  System.out.println(Thread.currentThread().getName());

避免死锁

死锁就是两个线程互相等待的状态:

  1. 线程A获取锁1
  2. 线程B获取锁2
  3. 线程B等待锁1释放
  4. 线程A等待锁2释放

我们可以看见两个线程在等待彼此对方释放锁,所以,双方都会产生停止状态—不作为。死锁出现在多个线程试图无序的获取多个锁的情况。那么怎样避免死锁情况?最佳方式是简化同步代码的书写,但是即使这样也不可以避免死锁问题。所以,多线程编码需要仔细设计线程以什么样的顺序获取锁—必须尽可能的小心设计!

如果我们觉得游戏程序可能出现了死锁情况,那么在1.4.1是HotSpot VM,所以Sun(虽然现在Java被Oracle卖了,但是Java兼容性一直都很好,我在VS Code中使用OpenJDK 15版本也能正常跑JDK 1.4版本的代码,请大家不要怀疑Java虚拟机产品的高品质性)提供了多种死锁侦测器。我们按Ctrl + 或者Ctrl + break(Windows中),JVM会显示线程状态信息,说明线程是等待还是发现了死锁。

//Thread A
public void waitForMessage(){
    while(hasMessage == false){
        Thread.sleep(100);
    }
}
//Thread B
public void setMessage(String message){
    …
    hasMessage = true;
}

线程A会每隔100毫秒不断的检查线程B是否发送消息。这时线程A可能出现因为等待消息而过度睡觉(oversleep)的现象。另外,如果发生多个线程等待一个消息会怎样?解决这个问题的方案是,如果让线程A在空闲时才通知线程B发送消息会,那么我们就不强迫线程A一分钟内10次查看是否有消息到达了。这样就解决了线程A过度睡觉的情况。

使用wait()和notify()方法

Sun公司提供这样的功能wait()和notify()方法,可以让我们方便的实现这样的策略。Wait()方法被使用在synchronized语句块中,当wait方法执行时,锁会被释放,而所有等待锁的线程得到通知。而notify()方法也只能使用在synchronized语句块中。Notify()方法通过所有等待相同锁的线程,如果多个线程在等待锁,那么其中线程会被JVM随机唤醒。

//Thread A
public synchronized void waitForMessage(){
    try{
        wait();
    }catch(InterruptedException ex){}
}
//Thread B
public synchronized void setMessage(String message){
    …
    notify();
}

在线程B呼叫notify之后,然后离开同步方法(释放该锁),线程A重新获取该锁,然后完成它的同步代码块。上面的示例只是返回而已。使用wait方法时,我们可以指定等待的最大时间值

wait(100);

表示100毫秒中不要被唤醒,这等同于呼叫了sleep方法。不好之处是:指定了wiat的线程在时间到之前不能指定结束,或者指定被唤醒。

notifyAll()方法是唤醒所有等待锁的线程,而不只唤醒一个等待的线程。因这些方法都属性Object类的,所以任何Java对象都可以被当成一把锁。

Java事件模型

不要看见有些书说Swing编程是单线程。但是实际不是这样,因为所有图形应用都至少有两个线程:主线程和AWT事件分发线程存在。主线程就是我们书写程序的主线程,它开始于我们书写主类(public类)中的main()方法。

AWT事件分发线程处理用户的输入事件:鼠标点击、键盘按下/释放,以及其它事件,比如窗体缩放等。这些事件可以访问我们的代码,它访问的方式是通过AWT事件分发线程来实现的!
无论什么时候使用线程都可以给用户带来更多的体验。也就是说,任何时候一些代码都可以停止或者持续更长的时间,因为让我们的代码在另外一个线程运行,这样我们的用户不会认游戏停止了。使用线程的情况如下:

  1. 当从本地文件系统装载许多文件时
  2. 当进行任何网络通信,比如发送高考分数到服务器
  3. 当进行海量级运算时,比如地形运算

那么什么时候不使用线程呢? 在游戏中有很多是一次性的事件,比如敌人跑开了,门打开了,子弹分飞等。这会导致一些人认为“我认为每个敌人都运行在自己的线程中”。其实不是这样,因为它浪费时间资源—一次运行太多的线程会耗尽系统的内存资源。如果这样书写代码可能产生以下问题:

  1. 一个敌人可能处理操作的中间区域,表示这种效果会一次在两个地方表示该敌人
  2. 每个线程的时间碎片可能不平衡,会导致敌人移动不协调
  3. 同步代码可能会导致不必要的延迟

处理这些问题时,我们在第二篇文章中会有更有效的方法来解决。

线程池

使用以上知识点,我们来创建一个线程池(thread pool)。一个线程池是一组线程,它们被用来执行任意任务。当然,如果我们用来模拟网络或者I/O连接时会限制它的数量,或者对于完成处理器级别的任务会限制它们的最大数目。

ThreadPool myThreadPool = new ThreadPool(8);
myThreadPool.runTask(new Runnable(){
    public void run(){
        System.out.println(“Do something cool here.”);
    }
});
myThreadPool.join();

runTask方法会立即返回,如果池中的所有线程都忙于执行任务,那么呼叫runTask()方法时会把一个新任务放到队列中,直到一个线程可以来运行它。

线程池代码演示

代码演示环境

  • 软件环境:Windows 10
  • 开发工具:Visual Studio Code
  • JDK版本:OpenJDK 15

ThreadPool(线程池)工具类

import java.util.LinkedList;

/**
    功能:一个线程池是一组有限数量的线程,它们被用来完成执行任务
    翻写作者:技术大黍
    备注:
        线程池使用ThreadGroup API来实现.线程组表示一个线程的集合。
        此外,线程组也可以包含其他线程组。线程组构成一棵树,在树中,
        除了初始线程组外,每个线程组都有一个父线程组。允许线程访问
        有关自己的线程组的信息,但是不允许它访问有关其线程组的父线
        程组或其他任何线程组的信息。

*/
public class ThreadPool extends ThreadGroup {
    private boolean isAlive; //表示线程是否活首
    private LinkedList taskQueue; //定义一个双向队列
    private int threadID; //保存线程的ID
    private static int threadPoolID; //表示线程池的ID

    /**
        在构造方法创建线程池
        @参数numThreads用来指定池中的线程个数
    */
    public ThreadPool(int numThreads) {
        super("线程池-" + (threadPoolID++));
        setDaemon(true);//让该线程组为精灵线程组
        
        isAlive = true;//设置初始值为true
        
        taskQueue = new LinkedList();//初始化任务队列
        for (int i=0; i任务开始执行时有序的到达时开始。
        @参数task表示运行的任务。如果不null,那么没有任务执行。
        @如果本ThreadPool关闭了,那么抛出IllegalStateException。
    */
    public synchronized void runTask(Runnable task) {
        //如果线程池的状态isAlive==false值
        if (!isAlive) {
            throw new IllegalStateException();//那么抛出异常
        }
        //如果任务不为null
        if (task != null) {
            //那么在任务把该任务加入任务阶段
            taskQueue.add(task);
            //然后唤醒空闲的线程执行该任务
            notify();
        }

    }

    /**
        功能:获取任务对象
        */
    protected synchronized Runnable getTask() throws InterruptedException{
        //如果任务队列不是空的
        while (taskQueue.size() == 0) {
            //如果线程池的状态isAlive==false值
            if (!isAlive) {
                return null; //那么返回null值
            }
            wait();//否则等待任务出现(添加任务)
        }
        //否则任务队列中的一个任务对象
        return (Runnable)taskQueue.removeFirst();
    }


    /**
        功能:关闭该线程池并且立即返回。让所有线程停止执行,并且所有等待任务停止执行。
            一旦一个ThreadPool被关闭了,那么该线程池中的所有的线程不再运行。
    */
    public synchronized void close() {
        //如果线程池是活的
        if (isAlive) {
              //那么置为false
            isAlive = false;
            //然后把任务队列清空
            taskQueue.clear();
            //最后终止线程池中所有线程的运行
            interrupt();
        }
    }


    /**
        功能:关闭该ThreadPool活动,然后等待所有的线程运行完成。这样所有等待的任务会被执行。
    */
    public void join() {
        // 当ThreadPool不再活动时唤醒所有等待的线程
        synchronized (this) {
            isAlive = false;
            notifyAll();
        }
        
        // 然后等待所有池中的线程对象执行完毕
        Thread[] threads = new Thread[activeCount()]; //创建所有池中的活动线程
        //把此线程组及其子组中的所有活动线程复制到指定数组中
        int count = enumerate(threads);
        //然后按序让每个线程执行完毕
        for (int i=0; i

ThreadPoolTest测试类

import static java.lang.System.*;
/**
    功能:书写一个测试类线程池的类
    作者:技术大黍
    */
public class ThreadPoolTest {
    
    public static void main(String[] args) {
        if (args.length != 2) {
            out.println("测试ThreadPool(线程池)任务.");
            out.println(
                "使用方法: java ThreadPoolTest 任务数 线程数");
            out.println(
                "  任务数 - integer: 表示需要执行的任务数量.");
            out.println(
                "  线程数 - integer: 表示在线程池中的线程的数量 ");
            return;
        }
        //读取命令行参数任务数值和线程数值
        int numTasks = Integer.parseInt(args[0]);
        int numThreads = Integer.parseInt(args[1]);
        
        //创建线程池对象
        ThreadPool threadPool = new ThreadPool(numThreads);
        
        //执行示例任务
        for (int i = 0; i < numTasks; i++) {
            threadPool.runTask(createTask(i));
        }
        
        //关闭线程池以等待所有线程完毕
        threadPool.join();
    }
    
    
    /**
        功能:创建简单Runnable对象用来每隔500毫秒打印ID
    */
    private static Runnable createTask(final int taskID) {
        return new Runnable() {
            public void run() {
                out.println("任务 " + taskID + ": 开始");
                
                //模拟长时间执行任务
                try {
                    Thread.sleep(500);
                }catch (InterruptedException ex) { 
                    ex.printStackTrace();
                }
                out.println("任务 " + taskID + ": 结束");
            }
        };
    }
}

运行效果

image-20210326115352692.png

总结

可能有的小伙伴会认为,这种JDK 1.4版本的Java代码写法看上没有现在的Java 8及以后版本的高大上啊,应该是过时的代码。

如果各位看官一定要这样认为,那我就只有呵呵,不解释了。不过,各位看官们是可以使用Java 8中的多线程框架API来尝试改写,我相信这是一个很好的学习方法。

你可能感兴趣的:(java游戏开发)