OO | 第二单元总结

蔡世源-23373253 2025-04-16 14:41:23

OO | 第二单元总结

目录

  • OO | 第二单元总结
  • HW1
  • 多线程设计
  • 线程安全
  • 线程协作
  • 线程终止
  • 运行策略: Look
  • 优先级处理
  • bug分析
  • HW2
  • 分配策略: 影子电梯
  • 锁与同步块
  • 输入结束 != 线程结束
  • bug分析
  • HW3
  • 双轿厢响应
  • 如何防止电梯撞车
  • 时序图
  • bug分析
  • 心得体会
  • Reference

HW1

多线程设计

首先想谈谈对线程的理解。我觉得应该把"线程类" (Thread) 和"普通类"区分开来,普通类是相对静态的,其内部存在改变状态的方法,但是需要线程类进行调度 (run),以真正改变状态。就像 Thysrael博客中的图 这样:

img

线程的设计 by Thysrael:

对于每个线程,我们都需要思考有没有必要开启这个线程,思考的标准就是仅用现有的线程有没有办法完成任务,对于每个类,我们需要确定他的方法会被哪些线程调用,数据会被哪些线程读写。

每一个对象一定属于一个或者多个线程,不然就就没人实例化,调用它,它就没有作用。

对于电梯调度,可以发现同时进行的任务有:

  • 接收乘客的乘电梯请求
  • 为乘客分配电梯
  • 电梯运行

所以对应地,我们开启 InputThread/DispatchThread/ElevatorThread 三个线程来执行这些任务。

于是有了这次作业的架构:

  • PersonQueue 是等电梯的总队
  • ProcessingQueue 是每个电梯对应的等待队列

线程安全

可以看到,personQueueprocessingQueue 实例会被多个线程共享,如何避免实例被两个线程同时操作?每个实例拥有一个独立的锁synchronized 方法可以使其每次只由一个线程运行。

线程协作

如果执行

obj.wait();

那么,当前线程便会暂停运行,并进入实例 obj 的等待队列,进入等待队列后,便会释放其实例的锁

obj.notify();

obj 的等待队列中的一个线程便会被选中和唤醒,然后就会退出等待队列,重新竞争锁,抢到后wait 的下一句开始执行

当没有乘客坐电梯当时候而且进程未结束时,电梯应该处于"等待"状态,当来人了再进行唤醒,避免无效的 while (true) 循环。

PersonQueue:

// 被DispatchThread调用
public synchronized Person poll() {
    if (persons.isEmpty() && !isEnd) {
        try {
            wait();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
    ...
}
// 由InputThread唤醒
public synchronized void addPerson(Person request) {
    persons.add(request);
    notifyAll();
}

ProcessingQueue:

// 被ElevatorThread调用
public synchronized void waiting() {
    try {
        wait();
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}
// 由DispatchThread唤醒
public synchronized void addRequest(Person request) {
    PriorityQueue<Person> queue;
    ...
    queue.offer(request);
    notifyAll();
}

特别注意,notify 可能造成死锁:

  • 四个消费者线程 (C1、C2、C3、C4) 开始工作,但由于队列为空,它们都无法从队列中取出数据进行消费,因此它们进入阻塞状态,进入等待池中,等待被唤醒

  • 接下来,四个生产者线程 (P1、P2、P3、P4) 开始执行。P1线程首先抢到锁,将一个元素放入队列中,并调用 notify() 方法唤醒等待池中的线程。由于等待池中只有消费者线程,notify()一定会唤醒一个消费者线程,这里假设唤醒的是C4。C4线程从等待池中被唤醒,进入锁池与其他线程争抢锁。假设P4线程也成功抢到锁,将元素放入队列,并再次调用 notify() 唤醒等待池中的线程,这次假设唤醒的是C1

  • 此时,队列中有两个元素,等待池中的线程是: 两个生产者线程P2、P3和两个消费者线程C2、C3。P2和P3作为生产者线程,尝试将数据放入队列,但发现队列已满,因此它们无法继续工作,只能进入等待池中,等待队列有空间

  • 锁池中的消费者线程C1和C4争抢锁。假设C4先获得锁,从队列中取出一个元素进行消费。消费完后,C4调用 notify() 唤醒等待池中的线程,这次假设唤醒的是C2线程。接着,C1线程获得锁,从队列中取出另一个元素进行消费,并在消费完后调用 notify() 唤醒等待池中的线程,这次唤醒的是C3线程

  • 此时,锁池中的两个消费者线程 (C2、C3) 开始竞争锁。假设C2先获得锁,但发现队列为空,因此它无法继续消费,只能重新进入等待池。接着,C3线程也获得锁,发现队列为空,同样进入等待池

  • 现在,等待池中有两个生产者线程 (P2、P3) 和两个消费者线程 (C2、C3) 。它们都在等待被唤醒,但没有线程能够唤醒它们。生产者无法向队列中放入元素,因为队列已满,消费者也无法消费,因为队列为空。导致死锁的发生

线程终止

参考《图解Java多线程设计模式》的 Two-Phase Termination 一章

在要停止线程时,我们会发出“终止请求”。这样,线程就不会突然终止,而是会先开始进行“打扫工作”。状态的变化是: 操作中 $\rightarrow$ 终止处理中 $\rightarrow$ 终止线程,一个模板:

public class CountupThread extends Thread {
    private long counter = 0;
    private volatile boolean shutdownRequested = false;

    public void shutdownRequest () {
        shutdownRequested = true; 
        interrupt();    // 线程正在wait的时候也能中断wait,处理shutdown
    }
    
    public boolean isShutdownRequested () {
        return shutdownRequested;
    }

    public final void run () {
        try{
            while (!isShutdownRequested()) {
                doWork();
            }
        } catch (InterruptedException e) {
        } finally {
            doShutdown();
        }
    }
    
    private void doWork() throws InterruptedException {
        counter++;
        System.out.println ("doWork: counter = " + counter) ;
        Thread.sleep(500);
    }
    
    private void doShutdown() {
        System.out.println("doShutdown: counter = " + counter);
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println("main: BEGIN");
        try {
            CountupThread t = new CountupThread();
            t.start();
            
            Thread.sleep(10000);
            
            System.out.println("main: shutdownRequest");
            t.shutdownRequest();
            
            System.out.println("main: join")
            t.join();    // 等待线程终止
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("main: END");
    }
}

运行策略: Look

From hyggge's blog:

  • 首先为电梯规定一个初始方向,然后电梯开始沿着该方向运动。
  • 到达某楼层时,首先判断是否需要开门
  • 如果发现电梯里有人可以出电梯(到达目的地),则开门让乘客出去;
  • 如果发现该楼层中有人想上电梯,并且目的地方向和电梯方向相同,则开门让这个乘客进入。
  • 接下来,进一步判断电梯里是否有人
  • 如果电梯里还有人,则沿着当前方向移动到下一层。否则,检查请求队列中是否还有请求(目前其他楼层是否有乘客想要进电梯)——
  • 如果请求队列不为空,且某请求的发出地是电梯"前方"的某楼层,则电梯继续沿着原来的方向运动。
  • 如果请求队列不为空,且所有请求的发出地都在电梯"后方"的楼层上,或者是在该楼层有请求但是这个请求的目的地在电梯后方(因为电梯不会开门接反方向的请求),则电梯掉头并进入"判断是否需要开门"的步骤(循环实现)。
  • 如果请求队列为空,且输入线程已经结束,则电梯线程结束。
  • 如果请求队列为空,且输入线程没有结束(即没有输入文件结束符),则电梯停在该楼层等待请求输入(wait)

电梯会在停在一层楼并且关门的状态下向 Strategy 获取下一步的运行策略。

优先级处理

ProcessingQueue 中使用 PriorityQueue 记录,在同时同层有很多人上一个电梯时会按优先级进入电梯。

public class ProcessingQueue {
    private HashMap<Integer, PriorityQueue> queueMapUp = new HashMap<>();
    private HashMap<Integer, PriorityQueue> queueMapDown = new HashMap<>();
    
    public ProcessingQueue() {
        for (int i = Constants.LOWEST_FLOOR; i <= Constants.HIGHEST_FLOOR; i++) {
            PriorityQueue<Person> queueUp = new PriorityQueue<>(new PersonComparator());
            queueMapUp.put(i, queueUp);
            PriorityQueue<Person> queueDown = new PriorityQueue<>(new PersonComparator());
            queueMapDown.put(i, queueDown);
        }
    }
    ...
}
public class PersonComparator implements Comparator<Person> {
    @Override
    public int compare(Person p1, Person p2) {
        if (p2.getPriority() > p1.getPriority()) {
            return 1;  // p2在前
        } else {
            return -1;
        }
    }
}

offer() 表示提供一个元素到队列而非添加,也就是说,可以不必须成功。

ps. 第二次作业涉及克隆,所以我弃用了优先队列。所以复杂的数据结构还是要谨慎使用。

bug分析

中测

当判断电梯前进方向是否有等候的乘客时,我的判断条件是 乘客在电梯的前进方向 && 乘客的目的地方向和电梯方向一致 ,这个判断逻辑有问题,不应该考虑乘客的目的地方向的。这个bug导致CTLE,定位它的方法是在 while(true) 中加上打印语句

public void run() {
    while (true) {
        Advice advice = elevator.getStrategy();
        System.out.println(advice.toString());      // debug,发现不断输出TURN
        ...
    }
}

互测

互测的时候发现在输入请求的时间相近的情况下,一位同学会产生这样的输出:

[   0.8290]CLOSE-F2-4
[  46.5500]ARRIVE-F3-6

可能是锁的问题,但比较遗憾,本地无法再次复现

HW2

在第一次作业的基础上,需要:

  • 自行设计调度策略,对乘客进行分配,并输出 RECEIVE 表示分配结果
  • 接收并处理临时调度 SCHE 请求,尽快到达目标楼层,并且不能进行任何上下乘客操作

分配策略: 影子电梯

选择什么样的分配策略?

  • Random策略。好处是不需要看电梯的状态,代码结构简洁很多,但坏处是不好复现bug
  • 剩下的策略都需要参考电梯的状态,我觉得也是很符合常理的。参考学长学姐的博客,最终选择使用影子电梯

影子电梯就是把当前电梯的状态 (包括在电梯里的乘客,和电梯即将处理的请求) 克隆一份,然后模拟运行,算出运行时间,对比各个电梯的时间,选择最优的那个来运送乘客。本质上是贪心算法,可以找到局部最优解 (无法得到全局最优)

影子电梯应该“运行”到什么时刻?

  • 乘客搭上电梯时?乘客送达目的地时?处理完全部请求时?
  • 想象现在暂时只有最后一名乘客需要乘坐电梯,我们应该考虑的是处理完所有请求用时最短的电梯

该不该把正在处理 SCHE 的电梯纳入考虑?

  • 不分的话,如果5个电梯都处于请求调度中,第6个电梯会承载大量的请求,显然不可取
  • SCHE 电梯的运行速度不慢,完全有可能比普通电梯更早地完成乘客请求
  • SCHE 期间无法 RECEIVE 乘客?——设立缓冲区 buffer,如果乘客分给了 SCHE 电梯接送,那么乘客会先到 ProcessingQueuebuffer 队列中,等到临时调度结束再转移到正式的处理队列中

锁与同步块

第一次作业相比,调度器需要监视电梯的状态,所以要为电梯加锁。

img

对于ElevatorThread中费时但并不影响状态的sleep操作,是不需要加锁的,只有改变状态的核心语句需要加锁。尽量使用同步块而非同步方法,例如:

public void move() {
    try {
        Thread.sleep(speed);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    synchronized (this) {
        currentFloor += direction;
    }
    TimableOutput.println("ARRIVE-" + Constants.FLOOR_R.get(currentFloor) + "-" + id);
}

另外影子电梯克隆的时候,需要获取电梯的候乘队列和电梯本身的锁,再开始克隆。

for (int i = 1; i <= Constants.ELEV_SUM; i++) {
    synchronized (elevatorMap.get(i)) {
        synchronized (queueMap.get(i)) {
            ...
        }
    }
}

在模拟的时候需要取2把锁,前面等待的时间会累积,所以遍历到第6个电梯的时候,实际上已经不是我们刚开始进行分配的时刻了。第6个电梯可能会悄悄进行一些操作,在时间上占有一定优势。但因为在Elevator里加的锁比较细了,我觉得延后的时间也不会太久,所以还是保持这个设计。

输入结束 != 线程结束

考虑这样的情况,输入已经结束,我们向电梯发出 END 指示。如果此时电梯正在进行临时调度,过了一段时间后,临时调度结束,又有乘客被迫下电梯,但是电梯觉得已经 END 了,线程便停止运行,这些乘客的请求得不到处理。可见,输入结束并不能让线程停止,要等处理完所有请求才能停止。为了解决这个问题,我在 PersonQueue 里新增了 todoNumscheNum 属性,用来记录未完成的乘客请求和调度请求数。

public class PersonQueue {
    private int scheNum = 0;
    private int todoNum = 0; 
}

相应地,增加结束条件:

public synchronized boolean isRealEnd() {
    notifyAll();
    return isInputEnd && todoNum == 0 && scheNum == 0;
}

DispathThread只有在 isRealEnd 的时候才会停止,并且告知电梯线程停止

public void run() {
    while (true) {
        if (personQueue.isRealEnd()) {
            for (ProcessingQueue queue : queueMap.values()) {
                queue.setEnd();
            }
            break;
        }
        ...
    }
}

bug分析

强测和互测均未出现bug,记录一些中测的bug:

乘客下电梯后的参数改变

乘客下电梯但是没有到达目的地时,修改了 startFloor 但是没改 direction ,导致电梯在错误的方向越走越远,无法停止……

HW3

双轿厢响应

双轿厢改造就是将轿厢安排到其他可用的电梯井中,此时在同一电梯井道内同时拥有两个独立的电梯轿厢。

"UPDATE-ACCEPT-A电梯ID-B电梯ID-目标楼层"
清空电梯并关门
"UPDATE-BEGIN-A电梯ID-B电梯ID"        # 开始改造
RECEIVE取消
期间不能参与调度
"UPDATE-END-A电梯ID-B电梯ID"        # 改造完成
电梯闪现到特定位置

在InputThread接收到Update请求后,把这个请求放入电梯A和B的处理队列中,生成 Coordinator 对象实例并传入电梯A和电梯B。电梯在处理请求时再据此修改运行参数。

由A电梯输出 UPDATE-BEGIN ,使用 CountDownLatch 来记录准备就绪的电梯数

public class Coordinator {
    private CountDownLatch readyLatch = new CountDownLatch(2);
    
    public void notifyEmptyComplete() {
        readyLatch.countDown();
    }
    
    public void awaitReady() throws InterruptedException {
        readyLatch.await();
    }
}

都准备好后,A电梯输出,B电梯睡觉,这样看起来公平一点 (bushi)。

try {
    coordinator.awaitReady();
    if (isA == 1) {
        TimableOutput.println("UPDATE-BEGIN-" +
                coordinator.getElevatorAId() + "-" + coordinator.getElevatorBId());
    } else {
        Thread.sleep(5);
    }
} catch {...}

需要注意的是,结束也要注意同步!不然容易出现B电梯处理完UPDATE后,接到乘客输出 RECEIVE ,但此时A还没处理完UPDATE,也还没输出 UPDATE-END !仿照 readyLatch 加上 sleepLatch 即可。

如何防止电梯撞车

两个电梯都能到达换乘层,但同时只能有一个电梯在换乘层。仿照学长学姐和讨论区大佬的做法,设置换乘层锁

public class Coordinator {
    private boolean isOccupied = false;     // 换乘层的锁

    public synchronized void competeTransferFloor() {
        if (isOccupied) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        isOccupied = true;
    }
    
    public synchronized void releaseTransferFloor() {
        isOccupied = false;
        notifyAll();
    }
}

电梯必须拿到锁才能移到换乘层,离开换乘层就必须释放锁。

public void move() {
    synchronized (this) {
        currentFloor += direction;
    }
    if (coordinator != null) {
        if (currentFloor == transferFloor) {
            coordinator.competeTransferFloor();
        }
    }
    TimableOutput.println("ARRIVE-" + Constants.FLOOR_R.get(currentFloor) + "-" + id);
    if (coordinator != null) {
        if (currentFloor == transferFloor + direction) {    // 是从换乘层离开的
            coordinator.releaseTransferFloor();
        }
    }
}

何时离开换乘层?Look策略会保证如果有未处理的请求,则向着请求方向移动,自然就离开换乘层了。除此之外,当没有未处理的请求并且电梯内没人时,我也会让电梯离开换乘层。

时序图

img

bug分析

buffer的转移

更新前准备的时候 (清空电梯到时候) 如果有请求,我会将其加到buffer内,准备好后只把 Process 移回了总队,buffer也应该放回总队的

轿厢的同步

就是前面说的双轿厢改造结束需要同步的问题,测出bug的数据是:

[1.0]UPDATE-1-2-B2
[1.0]UPDATE-3-4-B1
[1.0]UPDATE-5-6-B2
[1.3]1-PRI-29-FROM-F4-TO-F2
[1.4]2-PRI-12-FROM-B4-TO-F7

我的错误输出: ㄟ( ▔, ▔ )ㄏ

[   1.3940]UPDATE-BEGIN-1-2
[   1.3950]UPDATE-BEGIN-5-6
[   1.3950]UPDATE-BEGIN-3-4
[   2.3960]RECEIVE-2-2

心得体会

首先最最最重要的就是学习了多线程hhh!

线程安全方面,我在开始写代码前,会仔细考虑有哪些对象、有哪些线程会访问这些对象,对共享对象加锁,保证线程安全。另外注意线程获取对象锁的顺序,避免产生死锁。层次化设计方面,我感觉自己做得并不好,电梯类包含了运行策略的选择,非常臃肿。然后Shadow类又大量地复制电梯类的代码,感觉这样不是很好。

相比于上个单元的懒散,我在电梯上投入了更多,收获也是更多。希望在下半学期能更用心、认真地对待身边的一切 (๑˃̵ᴗ˂̵)و ヨシ!

Reference

...全文
43 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

269

社区成员

发帖
与我相关
我的任务
社区描述
2025年北航面向对象设计与构造
学习 高校
社区管理员
  • Alkaid_Zhong
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧