在代码第5行,给出一个标记变量suspendme,表示当前线程是否被挂起。同时,增加了suspendMe()和resumeMe()两个方法,分别用于挂起线程和继续执行线程。
在代码第21~28行,线程会先检查自己是否被挂起,如果是,则执行wait()方法进行等待。否则,则进行正常的处理。当线程继续执行时,resumeMe()方法被调用(代码第11~16行),线程t1得到一个继续执行的notify()通知,并且清除了挂起标记,从而得以正常执行。
2.2.6 等待线程结束(join)和谦让(yield)
在很多情况下,线程之间的协作和人与人之间的协作非常类似。一种非常常见的合作方式就是分工合作。以我们非常熟悉的软件开发为例,在一个项目进行时,总是应该有几位号称是“需求分析师”的同事,先对系统的需求和功能点进行整理和总结,然后,以书面形式给出一份需求说明或者类似的参考文档,然后,软件设计师、研发工程师才会一拥而上,进行软件开发。如果缺少需求分析师的工作输出,那么软件研发的难度可能会比较大。因此,作为一名软件研发人员,总是喜欢等待需求分析师完成他应该完成的任务后,才愿意投身工作。简单地说,就是研发人员需要等待需求分析师完成他的工作,然后,才能进行研发。
将这个关系对应到多线程应用中,很多时候,一个线程的输入可能非常依赖于另外一个或者多个线程的输出,此时,这个线程就需要等待依赖线程执行完毕,才能继续执行。JDK提供了join()操作来实现这个功能,如下所示,显示了2个join()方法:
public final void join() throws InterruptedException public final synchronized void join(long millis) throws InterruptedException
第一个join()方法表示无限等待,它会一直阻塞当前线程,直到目标线程执行完毕。第二个方法给出了一个最大等待时间,如果超过给定时间目标线程还在执行,当前线程也会因为“等不及了”,而继续往下执行。
英文join的翻译,通常是加入的意思。在这里感觉也非常贴切。因为一个线程要加入另外一个线程,那么最好的方法就是等着它一起走。
这里提供一个简单点的join()实例,供大家参考:
public class JoinMain { public volatile static int i=0; public static class AddThread extends Thread{ @Override public void run() { for(i=0;i<10000000;i++); } } public static void main(String[] args) throws InterruptedException { AddThread at=new AddThread(); at.start(); at.join(); System.out.println(i); } }
主函数中,如果不使用join()等待AddThread,那么得到的i很可能是0或者一个非常小的数字。因为AddThread还没开始执行,i的值就已经被输出了。但在使用join()方法后,表示主线程愿意等待AddThread执行完毕,跟着AddThread一起往前走,故在join()返回时,AddThread已经执行完成,故i总是10000000。
有关join(),我还想再补充一点,join()的本质是让调用线程wait()在当前线程对象实例上。下面是JDK中join()实现的核心代码片段:
while (isAlive()) { wait(0); }
可以看到,它让调用线程在当前线程对象上进行等待。当线程执行完成后,被等待的线程会在退出前调用notifyAll()通知所有的等待线程继续执行。因此,值得注意的一点是:不要在应用程序中,在Thread对象实例上使用类似wait()或者notify()等方法,因为这很有可能会影响系统API的工作,或者被系统API所影响。
另外一个比较有趣的方法,是Thread.yield(),它的定义如下:
public static native void yield();
这是一个静态方法,一旦执行,它会使当前线程让出CPU。但要注意,让出CPU并不表示当前线程不执行了。当前线程在让出CPU后,还会进行CPU资源的争夺,但是是否能够再次被分配到,就不一定了。因此,对Thread.yield()的调用就好像是在说:我已经完成一些最重要的工作了,我应该是可以休息一下了,可以给其他线程一些工作机会啦!
如果你觉得一个线程不那么重要,或者优先级非常低,而且又害怕它会占用太多的CPU资源,那么可以在适当的时候调用Thread.yield(),给予其他重要线程更多的工作机会。
2.3 volatile与Java内存模型(JMM)
之前已经简单介绍了Java内存模型(JMM),Java内存模型都是围绕着原子性、有序性和可见性展开的。大家可以先回顾一下上一章中的相关内容。为了在适当的场合,确保线程间的有序性、可见性和原子性。Java使用了一些特殊的操作或者关键字来申明、告诉虚拟机,在这个地方,要尤其注意,不能随意变动优化目标指令。关键字volatile就是其中之一。
如果你查阅一下英文字典,有关volatile的解释,你会得到最常用的解释是“易变的,不稳定的”。这也正是使用volatile关键字的语义。
当你用volatile去申明一个变量时,就等于告诉了虚拟机,这个变量极有可能会被某些程序或者线程修改。为了确保这个变量被修改后,应用程序范围内的所有线程都能够“看到”这个改动,虚拟机就必须采用一些特殊的手段,保证这个变量的可见性等特点。
比如,根据编译器的优化规则,如果不使用volatile申明变量,那么这个变量被修改后,其他线程可能并不会被通知到,甚至在别的线程中,看到变量的修改顺序都会是反的。但一旦使用volatile,虚拟机就会特别小心地处理这种情况。
大家应该对上一章中介绍原子性时,给出的MultiThreadLong案例还记忆犹新吧!我想,没有人愿意就这么把数据“写坏”。那这种情况,应该怎么处理才能保证每次写进去的数据不坏呢?最简单的一种方法就是加入volatile申明,告诉编译器,这个long型数据,你要格外小心,因为他会不断地被修改。
下面的代码片段显示了volatile的使用,限于篇幅,这里不再给出完整代码:
public class MultiThreadLong { public volatile static long t=0; public static class ChangeT implements Runnable{ private long to; ……
从这个案例中,我们可以看到,volatile对于保证操作的原子性是有非常大的帮助的。但是需要注意的是,volatile并不能代替锁,它也无法保证一些复合操作的原子性。比如下面的例子,通过volatile是无法保证i++的原子性操作的:
01 static volatile int i=0; 02 public static class PlusTask implements Runnable{ 03 @Override 04 public void run() { 05 for(int k=0;k<10000;k++) 06 i++; 07 } 08 } 09 10 public static void main(String[] args) throws InterruptedException { 11 Thread[] threads=new Thread[10]; 12 for(int i=0;i<10;i++){ 13 threads[i]=new Thread(new PlusTask()); 14 threads[i].start(); 15 } 16 for(int i=0;i<10;i++){ 17 threads[i].join(); 18 } 19 20 System.out.println(i); 21 }
执行上述代码,如果第6行i++是原子性的,那么最终的值应该是100000(10个线程各累加10000次)。但实际上,上述代码的输出总是会小于100000。
此外,volatile也能保证数据的可见性和有序性。下面再来看一个简单的例子:
01 public class NoVisibility { 02 private static boolean ready; 03 private static int number; 04 05 private static class ReaderThread extends Thread { 06 public void run() { 07 while (!ready); 08 System.out.println(number); 09 } 10 } 11 12 public static void main(String[] args) throws InterruptedException { 13 new ReaderThread().start(); 14 Thread.sleep(1000); 15 number = 42; 16 ready = true; 17 Thread.sleep(10000); 18 } 19 }
上述代码中,ReaderThread线程只有在数据准备好时(ready为true),才会打印number的值。它通过ready变量判断是否应该打印。在主线程中,开启ReaderThread后,就为number和ready赋值,并期望ReaderThread能够看到这些变化并将数据输出。
在虚拟机的Client模式下,由于JIT并没有做足够的优化,在主线程修改ready变量的状态后,ReaderThread可以发现这个改动,并退出程序。但是在Server模式下,由于系统优化的结果,ReaderThread线程无法“看到”主线程中的修改,导致ReaderThread永远无法退出(因为代码第7行判断永远不会成立),这显然不是我们想看到的结果。这个问题就是一个典型的可见性问题。
注意:可以使用Java虚拟机参数-server切换到Server模式。
和原子性问题一样,我们只要简单地使用volatile来申明ready变量,告诉Java虚拟机,这个变量可能会在不同的线程中修改。这样,就可以顺利解决这个问题了。
2.4 分门别类的管理:线程组
在一个系统中,如果线程数量很多,而且功能分配比较明确,就可以将相同功能的线程放置在一个线程组里。打个比方,如果你有一个苹果,你就可以把它拿在手里,但是如果你有十个苹果,你就最好还有一个篮子,否则不方便携带。对于多线程来说,也是这个道理。想要轻松处理几十个甚至上百个线程,最好还是将它们都装进对应的篮子里。
线程组的使用非常简单,如下:
01 public class ThreadGroupName implements Runnable { 02 public static void main(String[] args) { 03 ThreadGroup tg = new ThreadGroup("PrintGroup"); 04 Thread t1 = new Thread(tg, new ThreadGroupName(), "T1"); 05 Thread t2 = new Thread(tg, new ThreadGroupName(), "T2"); 06 t1.start(); 07 t2.start(); 08 System.out.println(tg.activeCount()); 09 tg.list(); 10 } 11 12 @Override 13 public void run() { 14 String groupAndName=Thread.currentThread().getThreadGroup().getName() 15 + "-" + Thread.currentThread().getName(); 16 while (true) { 17 System.out.println("I am " + groupAndName); 18 try { 19 Thread.sleep(3000); 20 } catch (InterruptedException e) { 21 e.printStackTrace(); 22 } 23 } 24 } 25 }
上述代码第3行,建立一个名为“PrintGroup”的线程组,并将T1和T2两个线程加入这个组中。第8、9两行,展示了线程组的两个重要的功能,activeCount()可以获得活动线程的总数,但由于线程是动态的,因此这个值只是一个估计值,无法确定精确,list()方法可以打印这个线程组中所有的线程信息,对调试有一定帮助。代码中第4、5两行创建了两个线程,使用Thread的构造函数,指定线程所属的线程组,将线程和线程组关联起来。
线程组还有一个值得注意的方法stop(),它会停止线程组中所有的线程。这看起来是一个很方便的功能,但是它会遇到和Thread.stop()相同的问题,因此使用时也需要格外谨慎。
此外,对于编码习惯,我还想再多说几句。强烈建议大家在创建线程和线程组的时候,给它们取一个好听的名字。对于计算机来说,也许名字并不重要,但是在系统出现问题时,你很有可能会导出系统内所有线程,你拿到的如果是一连串的Thread-0、Thread-1、Thread-2,我想你一定会抓狂。但取而代之,你看到的如果是类似HttpHandler、FTPService这样的名字,会让你心情倍爽。
2.5 驻守后台:守护线程(Daemon)
守护线程是一种特殊的线程,就和它的名字一样,它是系统的守护者,在后台默默地完成一些系统性的服务,比如垃圾回收线程、JIT线程就可以理解为守护线程。与之相对应的是用户线程,用户线程可以认为是系统的工作线程,它会完成这个程序应该要完成的业务操作。如果用户线程全部结束,这也意味着这个程序实际上无事可做了。守护线程要守护的对象已经不存在了,那么整个应用程序就自然应该结束。因此,当一个Java应用内,只有守护线程时,Java虚拟机就会自然退出。
下面简单地看一下守护线程的使用:
01 public class DaemonDemo { 02 public static class DaemonT extends Thread{ 03 public void run(){ 04 while(true){ 05 System.out.println("I am alive"); 06 try { 07 Thread.sleep(1000); 08 } catch (InterruptedException e) { 09 e.printStackTrace(); 10 } 11 } 12 } 13 } 14 public static void main(String[] args) throws InterruptedException { 15 Thread t=new DaemonT(); 16 t.setDaemon(true); 17 t.start(); 18 19 Thread.sleep(2000); 20 } 21 }
上述代码第16行,将线程t设置为守护线程。这里注意,设置守护线程必须在线程start()之前设置,否则你会得到一个类似以下的异常,告诉你守护线程设置失败。但是你的程序和线程依然可以正常执行。只是被当做用户线程而已。因此,如果不小心忽略了下面的异常信息,你就很可能察觉不到这个错误。那你就会诧异为什么程序永远停不下来了呢?
Exception in thread "main" java.lang.IllegalThreadStateException at java.lang.Thread.setDaemon(Thread.java:1367) at geym.conc.ch2.daemon.DaemonDemo.main(DaemonDemo.java:20)
在这个例子中,由于t被设置为守护线程,系统中只有主线程main为用户线程,因此在main线程休眠2秒后退出时,整个程序也随之结束。但如果不把线程t设置为守护线程,main线程结束后,t线程还会不停地打印,永远不会结束。
2.6 先干重要的事:线程优先级
Java中的线程可以有自己的优先级。优先级高的线程在竞争资源时会更有优势,更可能抢占资源,当然,这只是一个概率问题。如果运气不好,高优先级线程可能也会抢占失败。由于线程的优先级调度和底层操作系统有密切的关系,在各个平台上表现不一,并且这种优先级产生的后果也可能不容易预测,无法精准控制,比如一个低优先级的线程可能一直抢占不到资源,从而始终无法运行,而产生饥饿(虽然优先级低,但是也不能饿死它呀)。因此,在要求严格的场合,还是需要自己在应用层解决线程调度问题。
在Java中,使用1到10表示线程优先级。一般可以使用内置的三个静态标量表示:
public final static int MIN_PRIORITY = 1; public final static int NORM_PRIORITY = 5; public final static int MAX_PRIORITY = 10;
数字越大则优先级越高,但有效范围在1到10之间。下面的代码展示了优先级的作用。高优先级的线程倾向于更快地完成。
01 public class PriorityDemo { 02 public static class HightPriority extends Thread{ 03 static int count=0; 04 public void run(){ 05 while(true){ 06 synchronized(PriorityDemo.class){ 07 count++; 08 if(count>10000000){ 09 System.out.println("HightPriority is complete"); 10 break; 11 } 12 } 13 } 14 } 15 } 16 public static class LowPriority extends Thread{ 17 static int count=0; 18 public void run(){ 19 while(true){ 20 synchronized(PriorityDemo.class){ 21 count++; 22 if(count>10000000){ 23 System.out.println("LowPriority is complete"); 24 break; 25 } 26 } 27 } 28 } 29 } 30 31 public static void main(String[] args) throws InterruptedException { 32 Thread high=new HightPriority(); 33 LowPriority low=new LowPriority(); 34 high.setPriority(Thread.MAX_PRIORITY); 35 low.setPriority(Thread.MIN_PRIORITY); 36 low.start(); 37 high.start(); 38 } 39 }
上述代码定义两个线程,分别为HightPriority设置为高优先级,LowPriority为低优先级。让它们完成相同的工作,也就是把count从0加到10000000。完成后,打印信息给一个提示,这样我们就知道谁先完成工作了。这里要注意,在对count累加前,我们使用synchronized产生了一次资源竞争。目的是使得优先级的差异表现得更为明显。
大家可以尝试执行上述代码,可以看到,高优先级的线程在大部分情况下,都会首先完成任务(就这段代码而言,试运行多次,HightPriority总是比LowPriority快,但这不能保证在所有情况下,一定都是这样)。
2.7 线程安全的概念与synchronized
并行程序开发的一大关注重点就是线程安全。一般来说,程序并行化是为了获得更高的执行效率,但前提是,高效率不能以牺牲正确性为代价。如果程序并行化后,连基本的执行结果的正确性都无法保证,那么并行程序本身也就没有任何意义了。因此,线程安全就是并行程序的根本和根基。大家还记得那个多线程读写long型数据的案例吧!这就是一个典型的反例。但在使用volatile关键字后,这种错误的情况有所改善。但是,volatile并不能真正的保证线程安全。它只能确保一个线程修改了数据后,其他线程能够看到这个改动。但当两个线程同时修改某一个数据时,却依然会产生冲突。
下面的代码演示了一个计数器,两个线程同时对i进行累加操作,各执行10000000次。我们希望的执行结果当然是最终i的值可以达到20000000,但事实并非总是如此。如果你多执行几次下述代码,你会发现,在很多时候,i的最终值会小于20000000。这就是因为两个线程同时对i进行写入时,其中一个线程的结果会覆盖另外一个(虽然这个时候i被声明为volatile变量)。
01 public class AccountingVol implements Runnable{ 02 static AccountingVol instance=new AccountingVol(); 03 static volatile int i=0; 04 public static void increase(){ 05 i++; 06 } 07 @Override 08 public void run() { 09 for(int j=0;j<10000000;j++){ 10 increase(); 11 } 12 } 13 public static void main(String[] args) throws InterruptedException { 14 Thread t1=new Thread(instance); 15 Thread t2=new Thread(instance); 16 t1.start();t2.start(); 17 t1.join();t2.join(); 18 System.out.println(i); 19 } 20 }
图2.8展示了这种可能的冲突,如果在代码中发生了类似的情况,这就是多线程不安全的恶果。线程1和线程2同时读取i为0,并各自计算得到i=1,并先后写入这个结果,因此,虽然i++被执行了2次,但是实际i的值只增加了1。
图2.8 多线程的写入冲突
要从根本上解决这个问题,我们就必须保证多个线程在对i进行操作时完全同步。也就是说,当线程A在写入时,线程B不仅不能写,同时也不能读。因为在线程A写完之前,线程B读取的一定是一个过期数据。Java中,提供了一个重要的关键字synchronized来实现这个功能。
关键字synchronized的作用是实现线程间的同步。它的工作是对同步的代码加锁,使得每一次,只能有一个线程进入同步块,从而保证线程间的安全性(也就是说在上述代码的第5行,每次应该只有一个线程可以执行)。
关键字synchronized可以有多种用法。这里做一个简单的整理。
指定加锁对象:对给定对象加锁,进入同步代码前要获得给定对象的锁。
直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
直接作用于静态方法:相当于对当前类加锁,进入同步代码前要获得当前类的锁。
下述代码,将synchronized作用于一个给定对象instance,因此,每次当线程进入被synchronized包裹的代码段,就都会要求请求instance实例的锁。如果当前有其他线程正持有这把锁,那么新到的线程就必须等待。这样,就保证了每次只能有一个线程执行i++操作。
public class AccountingSync implements Runnable{ static AccountingSync instance=new AccountingSync(); static int i=0; @Override public void run() { for(int j=0;j<10000000;j++){ synchronized(instance){ i++; } } } //main函数参见本节第一段代码
当然,上述代码也可以写成如下形式,两者是等价的:
01 public class AccountingSync2 implements Runnable{ 02 static AccountingSync2 instance=new AccountingSync2(); 03 static int i=0; 04 public synchronized void increase(){ 05 i++; 06 } 07 @Override 08 public void run() { 09 for(int j=0;j<10000000;j++){ 10 increase(); 11 } 12 } 13 public static void main(String[] args) throws InterruptedException { 14 Thread t1=new Thread(instance); 15 Thread t2=new Thread(instance); 16 t1.start();t2.start(); 17 t1.join();t2.join(); 18 System.out.println(i); 19 } 20 }
上述代码中,synchronized关键字作用于一个实例方法。这就是说在进入increase()方法前,线程必须获得当前对象实例的锁。在本例中就是instance对象。在这里,我不厌其烦地再次给出main函数的实现,是希望强调第14、15行代码,也就是Thread的创建方式。这里使用Runnable接口创建两个线程,并且这两个线程都指向同一个Runnable接口实例(instance对象),这样才能保证两个线程在工作时,能够关注到同一个对象锁上去,从而保证线程安全。
一种错误的同步方式如下:
01 public class AccountingSyncBad implements Runnable{ 02 static int i=0; 03 public synchronized void increase(){ 04 i++; 05 } 06 @Override 07 public void run() { 08 for(int j=0;j<10000000;j++){ 09 increase(); 10 } 11 } 12 public static void main(String[] args) throws InterruptedException { 13 Thread t1=new Thread(new AccountingSyncBad()); 14 Thread t2=new Thread(new AccountingSyncBad()); 15 t1.start();t2.start(); 16 t1.join();t2.join(); 17 System.out.println(i); 18 } 19 }
上述代码就犯了一个严重的错误。虽然在第3行的increase()方法中,申明这是一个同步方法。但很不幸的是,执行这段代码的两个线程都指向了不同的Runnable实例。由第13、14行可以看到,这两个线程的Runnable实例并不是同一个对象。因此,线程t1会在进入同步方法前加锁自己的Runnable实例,而线程t2也关注于自己的对象锁。换言之,这两个线程使用的是两把不同的锁。因此,线程安全是无法保证的。
但我们只要简单地修改上述代码,就能使其正确执行。那就是使用synchronized的第三种用法,将其作用于静态方法。将increase()方法修改如下:
public static synchronized void increase(){ i++; }
这样,即使两个线程指向不同的Runnable对象,但由于方法块需要请求的是当前类的锁,而非当前实例,因此,线程间还是可以正确同步。
除了用于线程同步、确保线程安全外,synchronized还可以保证线程间的可见性和有序性。从可见性的角度上讲,synchronized可以完全替代volatile的功能,只是使用上没有那么方便。就有序性而言,由于synchronized限制每次只有一个线程可以访问同步块,因此,无论同步块内的代码如何被乱序执行,只要保证串行语义一致,那么执行结果总是一样的。而其他访问线程,又必须在获得锁后方能进入代码块读取数据,因此,它们看到的最终结果并不取决于代码的执行过程,从而有序性问题自然得到了解决(换言之,被synchronized限制的多个线程是串行执行的)。
2.8 程序中的幽灵:隐蔽的错误
作为一名软件开发人员,修复程序BUG应该说是基本的日常工作之一。作为Java程序员,也许你经常会被抛出的一大堆的异常堆栈所困扰,因为这可能预示着你又有工作可做了。但我这里想说的是,如果程序出错,你看到了异常堆栈,那你应该感到格外的高兴,因为这也意味着你极有可能可以在两分钟内修复这个问题(当然,并不是所有的异常都是错误)。最可怕的情况是:系统没有任何异常表现,没有日志,也没有堆栈,但是却给出了一个错误的执行结果,这种情况下,才真会让你抓狂。
2.8.1 无提示的错误案例
我在这里想给出一个系统运行错误,却没有任何提示的案例。让大家体会一下这种情况的可怕之处。我相信,在任何一个业务系统中,求平均值,应该是一种极其常见的操作。这里就以求两个整数的平均值为例。请看下面代码:
int v1=1073741827; int v2=1431655768; System.out.println("v1="+v1); System.out.println("v2="+v2); int ave=(v1+v2)/2; System.out.println("ave="+ave);
上述代码中,加粗部分试图计算v1和v2的均值。乍看之下,没有什么问题。目测v1和v2的当前值,估计两者的平均值大约在12亿左右。但如果你执行代码,却会得到以下输出:
v1=1073741827 v2=1431655768 ave=-894784850
乍看之下,你一定会觉得非常吃惊,为什么均值竟然反而是一个负数。但只要你有一点研发精神,就会马上有所觉悟。这是一个典型的溢出问题!显然,v1+v2的结果就已经导致了int的溢出。
把这个问题单独拿出来研究,也许你不会有特别的感触,但是,一旦这个问题发生在一个复杂系统的内部。由于复杂的业务逻辑,很可能掩盖这个看起来微不足道的问题,再加上程序自始至终没有任何日志或异常,再加上你运气不是太好的话,这类问题不让你耗上几个通宵,恐怕也是难有眉目。
所以,我们自然会恐惧这些问题,我们也希望在程序异常时,能够得到一个异常或者相关的日志。但是,非常不幸的是,错误地使用并行,会非常容易产生这类问题。它们难觅踪影,就如同幽灵一般。
2.8.2 并发下的ArrayList
我们都知道,ArrayList是一个线程不安全的容器。如果在多线程中使用ArrayList,可能会导致程序出错。那究竟可能引起哪些问题呢?试看下面的代码:
public class ArrayListMultiThread { static ArrayList<Integer> al = new ArrayList<Integer>(10); public static class AddThread implements Runnable { @Override public void run() { for (int i = 0; i < 1000000; i++) { al.add(i); } } } public static void main(String[] args) throws InterruptedException { Thread t1=new Thread(new AddThread()); Thread t2=new Thread(new AddThread()); t1.start(); t2.start(); t1.join();t2.join(); System.out.println(al.size()); } }
上述代码中,t1和t2两个线程同时向一个ArrayList容器中添加容器。他们各添加1000000个元素,因此我们期望最后可以有2000000个元素在ArrayList中。但如果你执行这段代码,你可能会得到三种结果。
第一,程序正常结束,ArrayList的最终大小确实2000000。这说明即使并行程序有问题,也未必会每次都表现出来。
第二,程序抛出异常:
Exception in thread "Thread-0" java.lang.ArrayIndexOutOfBoundsException: 22 at java.util.ArrayList.add(ArrayList.java:441) at geym.conc.ch2.notsafe.ArrayListMultiThread$AddThread.run (ArrayListMultiThread.java:12) at java.lang.Thread.run(Thread.java:724) 1000015
这是因为ArrayList在扩容过程中,内部一致性被破坏,但由于没有锁的保护,另外一个线程访问到了不一致的内部状态,导致出现越界问题。
第三,出现了一个非常隐蔽的错误,比如打印如下值作为ArrayList的大小:
1793758
显然,这是由于多线程访问冲突,使得保存容器大小的变量被多线程不正常的访问,同时两个线程也同时对ArrayList中的同一个位置进行赋值导致的。如果出现这种问题,那么很不幸,你就得到了一个没有错误提示的错误。并且,他们未必是可以复现的。
注意:改进的方法很简单,使用线程安全的Vector代替ArrayList即可。
2.8.3 并发下诡异的HashMap
HashMap同样不是线程安全的。当你使用多线程访问HashMap时,也可能会遇到意想不到的错误。不过和ArrayList不同,HashMap的问题似乎更加诡异。