我正在学习多线程,写了一个简单的程序来模拟教室,主要有三个类:白板,学生和教师。
WhiteBoard是一个单例对象,它保存了一个包含教师所写文本的字符串,以及从该字符串写入和读取的方法。教师对象将在WhiteBoard上写入,并等待所有学生读取。当所有学生都完成后,他们将通知教师再次写入。当他写入“end”时,程序结束。
当我测试代码时,我注意到有些学生不止一次地阅读文本。这是一个screenshot with the output。正如您所看到的,巴勃罗连续两次阅读文本。这怎么可能呢?我认为当我们调用方法 notify时(),则在监视器队列中等待的线程之一将被唤醒。这是否包括调用线程本身?如果不包括,为什么会发生这种情况以及如何正确地解决这个问题?
感谢您的帮助!下面是代码:
class WhiteBoard {
// Text written in the whiteboard
private String texto;
// Total number of students
private int noOfStudents = 0;
// Counter that keep control of which thread is accessing the whiteboard
private int counter = 0;
// Whiteboard instance
private static WhiteBoard wbInstance;
// Private constructor
private WhiteBoard() { }
// Returns the instance of the whiteboard (i'm using a singleton pattern)
public static WhiteBoard getInstance() {
if(wbInstance == null)
wbInstance = new WhiteBoard();
return wbInstance;
}
// This method is called when a new student is created
public void oneMoreStudent() {
noOfStudents++;
}
public synchronized void write(String str) {
// If it's not the teacher's turn to write, his thread waits
while(counter != 0) {
try {
wait();
}
catch(InterruptedException e) {
e.printStackTrace();
}
}
// Write's the text
texto = str;
// Increases the counter
counter++;
// Notify students threads
notifyAll();
}
public synchronized String read() {
// If it's not the students' turn to read, their threads wait
while(counter == 0) {
try {
wait();
}
catch(InterruptedException e) {
e.printStackTrace();
}
}
// Saves current text
String temp = texto;
if(counter == noOfStudents)
{
// If everyone read, reset counter and notify the teacher
counter = 0;
notify();
}
else
// If not, increase counter
counter++;
// Return the text
return temp;
}
}
// Student class
class Student implements Runnable {
// Student name
private String name;
// Whiteboard instance
private WhiteBoard wb;
// Constructor
public Student(String nome)
{
this.name = nome;
wb = WhiteBoard.getInstance();
wb.oneMoreStudent();
}
@Override
public void run() {
String txtCopiado;
while (true) {
// Reads the text
txtCopiado = wb.read();
// If the text is "end", breaks the while loop
if(txtCopiado.equals("end"))
break;
// Print student name and the text he read
System.out.println(name + ": " + txtCopiado);
}
}
}
// Teacher class
class Teacher implements Runnable {
// This is the array of texts he will write
private String[] textos = {"Ola amigos", "Java Programming", "Jacare", "end"};
// Whiteboard instance
private WhiteBoard wb;
// Constructor
public Teacher() {
wb = WhiteBoard.getInstance();
}
@Override
public void run() {
// Write each text on the whiteboard
for(String str : textos) {
wb.write(str);
// Print what the teacher wrote
System.out.println("Teacher: " + str);
}
}
}
// Main class
public class Classroom {
public static void main(String[] args) {
Thread prof = new Thread(new Teacher());
Thread alun1 = new Thread(new Student("1. Pablo"));
Thread alun2 = new Thread(new Student("2. Kiki"));
Thread alun3 = new Thread(new Student("3. Lucas"));
prof.start();
alun1.start();
alun2.start();
alun3.start();
}
}
2条答案
按热度按时间dffbzjpn1#
这段代码有很多小错误;错误的方式可能永远无法重现,因为Java内存模型(在处理使用这些过时/不明智的策略编写的多线程代码时相当相关)被“可能”填充到了边缘-JVM可能做一件事,也可能**做另一件事。(也许在你的硬件上,在这一天,在你的音乐播放器上有这首歌,它总是走一条路。明天它可能会走不同的路)。
你可能会想:什么鬼,这听起来彻头彻尾的愚蠢,为什么java会被设计成像一个邪恶的猴子的报酬?这个答案不可能是正确的!
遗憾的是,它真的是这样工作的。问题是,试图给予你一吨的保证(如:JVM运行的所有东西总是表现得像是按照某种顺序运行的),那么任何现实的Java应用程序都会运行得非常慢--考虑到现代CPU(“现代”--目前已超过20年)具有多个内核,每个内核都具有多个管道,并且CPU根本无法访问主内存(它们只能访问片上缓存,他们可以要求内存控制器将一个缓存页面交换为另一个缓存页面,但他们不能要求内存控制器读取或写入单个值,CPU几十年来一直无法做到这一点)。由于JVM应该在许多平台上运行,因此实际上只有两种选择:
正如您可能猜测的那样,#2是java的实际工作方式。
这更加突出了为什么你不应该这样做。使用
wait()
和notify()
需要非常高水平的专业知识 * 因为 * 它实际上是不可能测试的。获得这种专业知识非常困难:通常,你通过尝试来学习最好的东西,但在这里,尝试只会误导你:你最后写了一些看起来工作可靠的代码,但很久以后才意识到它不工作。只是 * 在你写它的时候 *,不管什么原因,“JVM可能做X或Y”的方面总是走同一条路。或者,在某个CPU上总是走一条路,但在另一个CPU上并不总是这样。我举几个例子:
noOfStudents
违反了HB/HA(我稍后会解释HB/HA是什么)。student 1线程启动后没有锁(例如,
Student
的run()
方法没有同步),然后在Whiteboard
示例上调用oneMoreStudent()
,然后运行noOfStudents++
。noOfStudents
是一个普通的非易失性字段。这是JMM违规。JMM规定允许JVM实现对语句重新排序(以任何顺序运行填充),并在任何时间本地缓存任何对象的任何字段(例如,为每个线程提供它自己的
noOfStudents
字段的小副本),或不。这意味着每个学生线程可以递增它本地缓存的noOfStudents
副本,(因此,每个学生线程都会看到noOfStudents为1,即使在所有3个线程都运行之后,从其他证据可以清楚地看出,它们早就通过了oneMoreStudent()
调用-请记住,如果您没有建立HBHA,JVM可以自由地重新排序或显示为重新排序)。这会扰乱它的循环,并解释你的代码不工作。这不一定是正确的解释或唯一的方法,只是,这是少数问题之一,可能导致你所看到的,取决于你的CPU,你的操作系统,以及月相。HB/羟基磷灰石
正如我所说的,JVM可以自由地对语句进行重新排序,并创建具有未定义同步行为的本地缓存(任何本地缓存都可以在任何时候覆盖或不覆盖任何其他缓存,没有任何保证)--除非涉及HB/HA。
HB/HA是一组规则,JMM保证"happens-before“/”happens-after“关系存在。一个操作(例如语句)是相对于另一个操作(另一个语句)的”HB“。
在这种情况下,JVM保证除了计时之外(事情需要多长时间),则不可能从HA行观察任何状态,例如它在HB行运行 * 之前 *。换句话说,“HappensAfter”行保证在HA要到的“HappensBefore”行之后发生,除非您不可能观察到这种情况,除非使用非常狡猾的技巧,例如使用计时,在这种情况下JVM可以随意忽略HB/HA规则。
获得HB/HA关系的一个普通方法是在同一线程中执行连续语句:给定
void foo() { a(); b(); }
,b()
相对于a()
是HA,原因显而易见。另一种方式是线程本身:
thread.start()
是相对于thread
结束启动的Runnable的run()
方法中的第一行的HB。类似地,thread.yield()
的返回是相对于thread
运行的最后语句的HA。建立HB/HA的常用方法是
synchronized
:如果2个线程都在执行锁定同一监视器的代码(synchronized(x)
,其中x
在两个线程中引用完全相同的对象),一个线程“赢”并首先运行-所有同步块都以某种未定义的顺序“排序”。无论是什么顺序,进入和退出都是HB/HA,正如您所期望的那样-如果一个线程“先运行”,那么它脱离synchronized
相对于其他线程进入它是HA。因为HB/HA保证您不会看到事情乱序运行,您解决了缓存问题-JMM保证不再是每个线程都可以对缓存值进行操作的情况。
因此,将
oneMoreStudent
设为synchronized
可以解决这个特定问题:一根线(随机地,或多或少地,不保证如果你连续启动3个线程,则第一个启动的线程也首先开始运行)首先赢得并传递synchronized {},更新该值,退出synchronized块,并且在这样做时HB相对于“赢得”并进入该块的下一个线程,并且因此,无法观察到第一个线程退出 * 之前 * 的状态:noOfStudents
已经递增是状态的改变,因此,第二个线程保证看到noOfStudents
是1,并将其递增到2。出于同样的原因,第三个学生线程最终看到2并递增到3,教师线程保证看到它是'3'。当然,除非。违规:取决于起始顺序
如果教师线程在所有3个学生线程初始化之前被导入(例如递增
noOfStudents
),然后所有的heck打破松散,因为教师线程将不会等待必要的学生读取量,因此将写更多的所有学生设法读取数据之前。事实上,您启动教师线程第四是无关紧要的,JVM并不保证线程实际上按顺序运行。2这是愚蠢的,线程的目的是尽可能同时执行任务。3因此,所有4个线程同时到达一个需要获取白板对象锁的点(synchronized
关键字可以做到这一点),因此一个线程随机获胜。与JMM的惯例一样,您无法通过测试来“证明”这一点。在您的硬件/Java/月相设置中,您很可能确实观察到学生线程首先运行,* 然后 * 教师线程。但这并不能保证,任何不这样做的JVM都不会坏。让事情变得更加复杂的是,
System.out
是一个非常大的铁打电话(需要大量时间),并获得各种锁,这意味着试图通过sysout来了解发生了什么,严重改变了应用的运行方式,所以你也不能这样做。并不是说见证任何事情都是有用的-当涉及到JMM时,JVM不必保持一致。单例冲突
你的
getInstance()
方法本身就是一个违规--你的代码检查whiteboard
字段是否为null
,如果是,就创建一个新的白板,这在表面上已经完全错了:如果两个线程同时调用getInstance()
,它们都会检查“is it null?”,然后都会说:是的,它们都创建了一个白板示例,都将其赋值给一个静态变量whiteboard
,该变量可能是本地缓存,也可能不是本地缓存。现在您有两个白板示例,显然其余代码将完全失败。更糟糕的是,由于重新排序规则,试图绕过这一点比你想象的要复杂得多。在网上搜索“单例双重检查锁定”以获得更多信息,但请注意,90%的教程都是完全错误的。
解决这个问题的方法是认识到**
getInstance()
是一个愚蠢的想法**。Java在启动时并不加载所有类,它加载主类,然后开始运行它。如果在运行它的过程中,出现了一些其他尚未卸载的类,则该类将按需加载。然而,一旦类被加载,它将保持加载状态(除非你正在从事类装入器的诡计,这超出了这个答案的范围)。Java本身因此需要进行高级单例锁定,因为JMM保证一个类最多只能加载一次。这意味着这是进行单例操作的唯一正确方法:这是因为JMM保证类只被加载一次,从而保证
INSTANCE
只被赋值一次。这不是“不必要的浪费”,因为类根本不会被加载,除非你真的在某个地方提到Whiteboard
。在这种情况下...你会需要那个示例。Java's在锁定行为中进行了烘焙,以确保类只加载一次,这是java中最有效的锁定构造。在极不可能的百万分之一的几率下,你可能会不时地引用一个类,而不是获取单例,并且你真的希望它延迟加载,这是正确的方法:
这是因为,正如我所说的,java在真正需要的时候才会加载类,这里直到有人调用
getInstance()
才需要它。这个代码段要复杂得多,唯一的优点是你可以将Whiteboard
作为一个类型来提及,而 * 不用 * 调用.getInstance()
,从而避免了加载。这通常是不相关的。所以您不应该为这个通常不必要的复杂示例而烦恼。结束语-你真正应该做的是什么
以上任何一个都可以解释你所看到的。我不会对你写的代码感到惊讶。如果你修复了所有3个,代码工作可靠,你真的不知道你是否真的修复了所有的错误,或者仍然存在的错误只是一个性质,不会发生在你的硬件/操作系统组合。
因此,这是火箭科学。你不应该这样做。有不同的编程模型,是非常上级的,更容易推理:
1.大范围并发--通过一个专门为它设计的媒介发送所有线程间的通信,比如一个数据库(设置一个事务,瞧),或者一个消息队列。
1.使用
java.util.concurrent
包中的一些东西。在这里,CyclicBarrier
可以工作,或者可能是它拥有的许多集合中的一个。qmb5sa222#
下面是对该主题感兴趣的人的更新:)
我使用Executor框架修复了这个问题,我从@rzwitserloot的回答中得到了一些提示,并使用线程池和CompletableFuture类编写了一个更干净、更稳定的代码。
Student类的对象不再依赖于WhiteBoard类中的计数器,相反,它们只是读取文本并将其打印到终端上--如果它不是“end”字符串的话。
Teacher类现在有了一个内部计数器,可以跟踪它必须在白板上写什么文本; WhiteBoard类现在有了一个适当的Lock对象,可以避免read()方法中的争用条件; write()方法不需要锁,因为只有Teacher线程才能访问它。
该get()方法在到达字符串“end”时将返回 false。我用它来退出while循环。如果你想知道我为什么使用get()方法调用学生任务(因为这基本上返回“void”)。因为这将使主线程等待所有学生线程完成任务。否则,程序可能会突然结束。下面是新输出的屏幕截图:
正如你所看到的,学生们随机地访问和打印白板上的文字,但是老师总是会先写,这就是他们的意图:)
如果需要,可以使用
thenRunAsync(Runnable action)
方法定义学生线程的顺序。下面是新代码: