背景
最近在找工作,然后正好聊到一个合适的岗位。按照流程,在面试的最后,需要做一道笔试题。而恰恰是这道笔试题,带给了之前的普通算法面试所没有带给我过的思考。让我重新审视自己写代码的术与道。
题目
由于我是面试Java。所以题目也是一道Java的面试题。
如下:
请使用三个线程循环打印如下字符串“abcabcabcabcabcabc...”, 一个线程打印“a”一个线程打印“b”以此类推。
我的思路和解题过程
由于我一直在一线的编程岗位,并且已经工作了有七八年的时间了,所以对我来说,我一读到这个题目,就知道这个题的核心考察点是Java线程间的同步和通信。也正是因为我的大意,才中了其中相当多的陷阱。
由于是在线上答题, 面试官和我直接视频通话,所以我也比较放松,我想着既然是面试写代码,就应该表现出自己平时工作写代码的习惯和风格,没必要一口气完全写对,可以一步一步精进,我没太留意时间,因为题目很简单,时间肯定够用的。
既然是面试笔试,代码风格肯定也重要的。所以我给出了一个已知是错误的版本,并且自测了一下。虽然代码是100%错误的,但是我的运行结果确实完全符合题目预期的(从ide上简单来看)。
代码如下:
private static Runnable printChar(char c){
return ()->{
System.out.print(c);
};
}
public static void main(String[] args) {
Runnable aT = printChar('a');
Runnable bT = printChar('b');
Runnable cT = printChar('c');
for (int i = 0;i<10000;i++){
aT.run();
bT.run();
cT.run();
}
}
这段代码,其实有相当多的错误,我们先按下不表,只是说其中的一个思路上的问题(这代码实际在实现上,简直错的不沾边),就是线程间,没有通信,没有控制顺序,如果是真的在多线程的场景下,这段代码一定会执行出问题(忽略没有使用Thread.start()的错误)。
于是,我想到,使用信号量来处理这个问题,创建一个临界资源,谁拿到这个临界资源,谁就输出字符,于是,我把代码改成了下面这样:
private static Semaphore semaphore = new Semaphore(1);
private static Runnable printChar(char c ,Semaphore semaphore){
return ()->{
try {
semaphore.acquire();
}catch (InterruptedException e){
throw new RuntimeException(e);
}
System.out.print(c);
semaphore.release();
};
}
public static void main(String[] args) {
Runnable aT = printChar('a',semaphore);
Runnable bT = printChar('b',semaphore);
Runnable cT = printChar('c',semaphore);
for (int i = 0;i<10000;i++){
aT.run();
bT.run();
cT.run();
}
}
我太得意了,我简直是天才,一下子就控制住了线程会自己随意输出的问题,现在他们会通过一个semaphore信号量来输出,嗯,非常满意。交卷。
这个时候,面试官还问了我一句,你自己试过了吗,我说:嗯。
我在想,我甚至抽象了一个Runnable方法来打印一个字符,并且使用的是 System.out.print而不是 System.out.println方法,因为println方法会导致每打印一个字符就会换行,我简直拥有太良好的编码习惯了,并且相当注意细节。
解答的错误之处
😀,笑死,太多了,真的太多了,列不完,根本列不完。
思路层面的错误
首先第一个问题,就是解答思路整个都有缺陷,只是控制了临界资源,却没有控制状态,即使每次只有一个线程获取到信号量,也依然有可能,一直是其中的a线程一直获取到信号量,导致打印出aabc类似的串,所以在这里,我们一定需要一个状态机来控制a后面一定是b,b后边一定是c,c后面一定是a
Semaphore信号量满足不了整个需求,需要其他带状态的线程间可见的带原子化操作的类来满足状态机的设计。
代码层面的错误
很多编程基本功都忘记光了,相当多的错误
- 在同一个类里面,已经定义了方法均可见的类的成员变量的情况下,还在方法签名中需求其传入其值。
private static Semaphore semaphore = new Semaphore(1);
private static Runnable printChar(char c ,Semaphore semaphore);
- 没有把资源获取之后的操作,写在fa当中,如果业务出现异常,会极大可能造成死锁
try {
> semaphore.acquire();
>}catch (InterruptedException e){
> throw new RuntimeException(e);
>}
>System.out.print(c);
>semaphore.release();
- aT.run()这是一个致命的错误,最起码,你应该使用
Thread aT = new Thread(printChar('a',semaphore));
aT.start();
因为你如果不适用start()方法来启动一个线程,Java就不会创建一个线程来执行你的代码,而是使用当前线程直接执行你的run()方法。
这个致命的错误,会导致我不可能在ide中验证到我的代码错误,因为它们一定会在for循环中安全的顺序执行。
而且,就算直接替换run方法到start方法也不可行,因为线程不能二次start。所以,这里暴露出来我的很多问题。
习惯层面的错误
对,这是一个比较难发现的错误,我们常常会忽视这个点,就是如何去确认我们的程序。
实际上,在这次面试里,由于上面的 aT.run()这个问题,导致我不能验证出我的问题。但是,这却完全不是编码习惯层面的错误,在Java的多线程环境中,我们可能需要一个更完备的方案来验证自己的代码,因为你就算使用start()方法,也非常有可能得到一个有序的结果,这一点很容易让人再次陷入陷阱。我们可能需要使用一段代码来验证自己的另一段代码,所以我们需要一些手段来监控自己的程序过去的输出结果是否正确。从控制台获取肯定成本太高。我们可以在打印的时候,同时也使用一个串来记录输出。这样,可以另外起一个线程去监控这三个线程是否工作正常。
一个思路是:使用StringBuffer 来记录打印的字符,然后在主线程中去验证当前的打印是否正确。
注意到题目,是要一直循环打印一个字符串,而我我只循环了100下,这也是我为了简单验证所作的不足的地方。
修改后的代码
好了,看了自己的这坨小小的不到30行的程序,我具然在里面埋了这么多的坑,太amazing了。
我们现在根据上面的思路,修改一版
public class ABC {
private static volatile char current = 'a';
private static Semaphore semaphore = new Semaphore(1);
private static Runnable printChar(char c, char next){
return ()->{
while (true){
try {
semaphore.acquire();
if (current != c){
}else {
System.out.print(c);
current = next;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
semaphore.release();
}
}
};
}
public static void main(String[] args){
Thread aT = new Thread(printChar('a','b'));
Thread bT = new Thread(printChar('b','c'));
Thread cT = new Thread(printChar('c','a'));
aT.start();
bT.start();
cT.start();
}
}
在这一版里,我们使用一个全局可见的current 来控制程序的状态,然后在子线程里面循环打印自己的字符,然后通过获取临界资源来控制自己能否打印,如果不是对应的状态,我们就不打印。
在这里,本质上信号量这个资源不是必须的,但是我们需要考虑到一种情况,就是可能会有多个专门打印 a 的线程的情况,所以,这个题目你甚至可以只根据状态机进行打印。因为就起了3个线程,每个线程都只做自己的事情。
好了,我们再说回来一点,就是我们需要去写一套程序来验证我的程序是不是不论怎么输出都是对的,所以我们需要根据多线程的特性去设计一个验证的程序来验证我们的输出。
总结
这次面试,如果我是面试官,我大概率会给这个候选人一个 不符合预期 的评价。
- 从对Java的理解上来看,对线程的使用不熟,不符合八年Java开发的基础水平
- 从对题目的理解和处理来看,过于粗心毛躁,急于求成。
- 从编码习惯来看,对验证程序的方式方法也没有形成自己的方法论,幻想就使用一个简单的main方法在for循环里面执行一下来肉眼观察自己的程序是否运行的符合预期。
所以总体来看,这次的笔试对我来说是失败的,也从侧面反映了就算是看起来再简单的问题,其中可能蕴含着相当多的自身的不足与缺陷。我们应该从中去发现和找到这些问题,不断地提升自己。
最后,我也想对这个面试题做一个评价,我觉得这个笔试题非常好。考察的问题很全面,不管是考察面试者对Java的多线程编程,还是对面试者的编码习惯,验证习惯及思路都有比较全面的考察和展示。