只要继承 Thread 类或是实现 Runnable 接口,就可以让对象具有多线程功能。入门线程是简单的,但如果多个线程共享某些数据,数据的同步(一致性、整体性)问题就要特别注意。
1. 同步化
如果程序只是一个单线程,即单一流程的程序,那么只要保证程序逻辑正确,程序通常就可以正确地执行你想要的功能。但当程序是多线程程序,即多流程同时执行时,那么就需要注意更多的细节。例如在多线程共享同一对象的数据时。
如果一个对象所持有的数据可以被多线程同时共享存取,则必须考虑到数据同步的问题。所谓数据同步指的是两份数据的整体性和一致性。例如对象 A 有 name
与 id
两个属性,而有一份 B 数据有 name
与 id
的数据要用来更新对象 A 的属性,如果 B 的 name
与 id
设定给 A 对象完成,则称 B 与 A 同步。如下图所示:
如果 B 数据在更新了 A 对象的 name
属性时,突然插入了一份 C 数据更新了 A 对象的 id
属性,则显然 B 数据与 A 就不同步,C 数据与 A 也不同步。如下图所示:
数据在多线程下共享时,容易由于同时多个线程可能更新同一个对象的信息,而造成对象数据的不同步,因为数据的不同步而可能引发的错误通常不易察觉,而且可能是在程序执行了几千万次之后,才会发现错误。这通常会发生在产品已经上线之后,甚至是程序已经执行了几年之后。
这里举个简单的例子,设计了一个 Person 类。
1 | package com.sunzn.sync; |
在 Person 类中,可以设定 name
与缩写 id
,并简单检查一下 name
与 id
的第一个字符是否相同。这里假设 id
的第一个字符就是 name
缩写的第一个字符,如果两个字符不相同,就显示错误信息。单就这个类本身而言,它并没有任何的错误,但如果它被用于多线程的程序中,而且同一个对象被多个线程存取时,就有可能发生错误。通过下面的测试代码来看看 Person 在多线程共享数据时会发生什么问题。
1 | package com.sunzn.sync; |
下面是我计算机上执行的结果,每次执行的结果会有所不同。
1 | 开始测试..................... |
如果以单线程的观点来看,上面的信息在测试时不会出现,然而在这个程序中却出现了错误,而且重点是,第一次错误发生在第 354929 次的 setNameAndID()
执行时。如果程序完成并开始应用于实际场合之后,这个时间点可能是几个月甚至几年之后。问题出在这里:
1 | public void setNameAndID(String name, String id) { |
虽然传递给 setNameAndID()
的变量并没有问题,在某个时间点时,thread1 设定了 Justin Lin、J.L 给 name
与 id
,在进行 if 检查的前一刻,thread2 可能此时刚好调用 setNameAndID("Martin Yao", "M.Y")
。在 name
被设定为 Martin Yao 时,check()
开始执行,此时 name 等于 Martin Yao,而 id
还是 J.L。所以 check()
就会返回 false,结果就显示了错误信息。
必须同步数据对对象的更新,方法在有一个线程正在设定 person 对象的数据时,不可以被另一个线程同时进行设定。可以使用 synchronized
关键词来进行这个动作。
1.1 方法同步
synchronized
关键词用于方法上,可以让方法的范围(Scope)内都成为被同化区域。例如:
1 | public synchronized void setNameAndID(String name, String id) { |
被标记为 synchronized
的方法就成为被同步化区域的一员,当线程执行某个对象的被同步化方法时,线程会在对象上得到一个锁定,锁定所有同样被标记为 synchronized
的区域,不会让其他的线程来执行这些区域。其他线程必须等待当前线程执行完被同步化方法,并解除对对象的锁定后,才有机会取得对象上执行方法的权力。就上面的代码片段而言,若有线程在执行 setNameAndID()
时,其他线程必须等待当前线程执行完毕,才有机会执行 setNameAndID()
,将之前报错的方法 setNameAndID()
加上 synchronized
关键字后,程序就不会输出错误信息了。
被同步化的区域在有一个线程占据时就像一个禁区,不允许其他线程进入。由于同时只能有一个线程在被同步化区域,所以更新共享数据时,就像单线程程序在更新数据一样,以保证对象中的数据会与给定的数据同步。
1.2 区块同步
synchronized
不只用于方法上,也可以用于限定某个程序区块为被同步化区域。例如:
1 | public void setNameAndID(String name, String id) { |
这个代码片段的意思是,在线程执行至 synchronized
设定的被同步化区块时锁定当前对象,这样就没有其他线程可以来执行这个被同步化区块。这种方式可以应用于你不想锁定整个方法区块,而只是想在更新共享数据时再确保对象与数据的同步化。由于只锁定方法中的某个区块,在执行完区块后即释放对象的锁定,以便让其他线程有机会对对象进行操作,相对于锁定整个方法区块效率较高。
1.3 对象同步
synchronized
还可标记某个对象要求同步化。例如在多线程存取同一个 ArrayList
对象时,由于 ArrayList
并没有实现数据存取时的同步化,所以当它使用于多线程环境时,必须注意多个线程存取同一个 ArrayList
时,有可能发生两个以上的线程将数据存入 ArrayList
的同一个位置,造成数据的相互覆盖。为了确保数据存入时的正确性,可以在存取 ArrayList
对象时要求同步化。例如:
1 | synchronized (arrayList) { |
同步化确保数据的同步,但所牺牲的就是在于一个线程占据同步化区块,而其他线程等待它释放区块执行权时的延时。这在线程少时可能看不出来,但在线程多的环境中必然造成一定的效率问题。
2. 等待和通知
wait()
、notify()
与 notifyAll()
是由 Object 类所提供的方法,在定义自己的类时会继承下来(记得 Java 中所有的对象最顶层都继承自 Object)。wait()
、notify()
与 notifyAll()
都被声明为 final,所以无法重新定义它们。通过这 3 个方法你可以要求线程进入等待,或是通知线程回到 Runnable 状态。
注意:调用对象的 wait()、notify() 与 notifyAll() 方法前,必须获得对象锁,也就是必须写在被同步化的方法或区块中。
必须在被同步化的方法或区块中调用 wait()
方法,当对象的 wait()
方法被调用时,当前的线程会被放入对象的等待集合(Wait Set)中,线程会解除对对象的锁定,其他的线程可竞争执行被同步化区块。被放在等待集中的线程将不参与线程的排队,wait()
可以指定等待的时间,如果指定时间,则时间到之后线程会再度加入排队,如果指定时间为 0 或不指定,则线程会持续等待中,直到被中断,或是被告知参与排队。
当对象的 notify()
被调用时,它会从当前对象的等待集中通知一个线程加入排队。被通知的线程是随机的,被通知的线程会与其他正在执行的线程共同竞争对对象的锁定。如果调用 notifyAll()
,则所有在等待集中的线程都会被通知加入排队,这些线程会与其他正在执行的线程共同竞争对对象的锁定。
简单地说,当线程调用到对象的 wait()
方法时,表示它要先让出对象的被同步区使用权并等待通知,或是等待一段指定的时间,直到被通知或时间到时再与其他线程竞争。如果可以执行时就从等待点开始执行,这就好比某人让你做事,做到一半时某人叫你等候通知(或等候 1 分钟之类的),当你被通知(或时间到时)某人会继续为你服务。
说明 wait()
、notify()
或 notifyAll()
应用最常见的一个例子,就是生产者(Producer)与消费者(Consumer)的例子:生产者会将产品交给店员,而消费者从店员处取走产品,店员一次只能持有固定数量产品,如果生产者生产了过多的产品,店员叫生产者等一下,如果店中有空位放产品了再通知生产者继续生产,如果店中没有产品了,店员会告诉消费者等一下,如果店中有产品了再通知消费者来取走产品。
以下举一个最简单的:生产者每次生产一个 int 整数交给店员,而消费者从店员处取走整数,店员一次只能持有一个整数。先定义店员 Clerk 类。
1 | package com.sunzn.sync; |
Clerk 类会告诉生产者、消费者何时要等待,也会通知生产者、消费者可以生产或取走产品,接着再定义生产者 Producer 类。
1 | package com.sunzn.sync; |
Producer 类会生产 1 到 10 的整数作为产品,产品会交给 Clerk 的实例,消费者再从店员处取走产品。下面再定义消费者 Consumer 类。
1 | package com.sunzn.sync; |
最后编写测试代码来测试生产者、消费者与店员的行为。
1 | package com.sunzn.sync; |
程序执行结果如下:
1 | 生产者开始生产整数...... |
生产者会生产 10 个整数,而消费者会消耗 10 个整数。由于店员处只能放置一个整数,所以只能每生产一个就消耗一个,其结果如上所示是无误的。
如果一个线程进入对象的等待集中,可以中断它的等待,这时将会发生 InterruptedException 异常。可以使用线程对象的 interrupt()
方法来中断等待的动作。