Java Threads 多线程10分钟参考手册

1         同步

                如何同步多个线程对共享资源的访问是多线程编程中最基本的问题之一。当多个线程并发访问共享数据时会出现数据处于计算中间状态或者不一致的问题,从而影响到程序的正确运行。我们通常把这种情况叫做竞争条件(race condition),把并发访问共享数据的代码叫做关键区域(critical section)。同步就是使得多个线程顺序进入关键区域从而避免竞争条件的发生。

1.1       Synchronized关键字

               Synchronized是Java多线程编程中最常用的关键字。所有的Java 对象都有自己唯一的隐式同步锁。该锁只能同时被一个线程获得,其他试图获得该锁的线程都会被阻塞在对象的等待队列中直到获得该锁的线程释放锁才能继续工作。Synchronized关键字通常有两种用法。当Synchronized关键字用于类方法定义中时,表示所有调用该方法的线程都必须获得当前对象的锁。这种方式比较简单,但是同步的粒度比较大,当一个线程要执行某个对象的同步方法的时候,必须同时没有任何其他线程在执行该对象的任一同步方法。此外,同步方法中的所有代码均在同步块中,获得锁的线程必须在执行完所有的代码离开该方法后才会释放锁,这些代码中可能只有一部分涉及到对共享资源(例如成员变量)的访问需要同步,其余则不需要,那么这样粗粒度的同步显然增加了其他线程的等待时间。Synchronized的另一种 用法允许作用在某个对象上,并且只同步一段代码而不是整个方法。

 

synchronized (object)  {

 // 需要同步的代码

}

 

这里synchronized所作用的对象可以是类的某个成员变量,也可以是这个类对象(用this表示)。这种用法使得程序员可以根据需要同步不同的成员变量,而不总是当前类对象,提高了灵活性。

 值得一提的是,并不是只有对象才有锁,类本身也有自己的锁,这使得static方法同样可以用synchronized来修饰。访问同步static方法的线程需要获得类的同步锁才能继续执行。

1.2       Volatile关键字

                在Java内存模型中每个线程拥有自己的本地存储(例如寄存器),并且允许线程拥有变量值的拷贝。这使得本来不需要同步的一些原子操作,例如boolean成员变量存储和读取也变得不安全。设想我们有个叫做done的boolean成员变量和一个当done为true时才会停止的循环,该循环由后台线程执行,另一个UI线程等待用户输入,用户按下某个按钮以后会把done设成true从而终止循环。由于UI线程自己本地拥有done的拷贝,用户在按下按钮时只是把自己本地的done设成了true而没有及时更新主内存中的done,所以后台线程由于看不到done的改变而不会终止。即使主内存中的done变化了,后台线程也会因为自己本地的变量值没有及时更新而没有察觉到done的变化。解决这一问题的方法之一是为done提供synchronized的setter和getter方法,这是因为获得同步锁会迫使所有变量的值从临时存储(寄存器)写会主内存。除此之外,Java提供了一个解决这个问题更为优雅的方法:Volatile关键字。每次使用volatile变量,JVM都会保证从主内存中读取它的值;同样每次修改volatile变量,JVM都会把值写回到主内存中。

                Volatile适用的场景比较严格,必须很清楚地看到volatile只是告诉JVM对于该变量的读写必须每次都在主内存中进行而禁止使用临时的拷贝来优化,它只是出于JVM特殊的内存模型的需要,并没有同步的功能。因此只有对volatile变量进行的原子操作(读取和赋值)才是线程安全的,像自增++自减--这样包含多个命令的操作仍然需要其它的同步措施。

                另一个需要注意的的地方是当用volatile修饰数组的时候,它只是说数组的引用是volatile的,而数组中的元素还是和普通变量一样,可能被JVM优化,我们无法为数组中的元素加上volatile修饰。解决上述问题的方法是使用Atomic变量。作为使用volatile修饰数组的一个例子,可以参考java.util.concurrent.CopyOnWriteArrayList。它的add操作是通过复制原来的数组并把新元素添加到新数组末尾然后再把内部数组引用变量指向新数组来实现的,因此数组变量经常会被修改,需要使用volatile。

1.3       显式锁Lock

                尽管synchronized关键字可以解决大多数同步问题,J2SE5.0还是引入了Lock接口。相比使用synchronized关键字获取对象隐式的同步锁,我们称Lock为显式锁。使用显式锁的一个显而易见的好处是它不再属于某个对象,从而可以在多个对象之间共享它。Lock接口有lock()和unlock()两个方法,使用它们和使用synchronized关键字类似,在进入需要同步的代码之前调用lock,在离开同步代码块时调用unlock。通常unlock会被放在finally中以保证即使同步代码块中有异常发生,锁仍然可以被释放。

                和使用synchronized关键字和lock()方法总是把未能获得锁的线程阻塞不同,Lock接口还提供了非阻塞的tryLock()方法。调用tryLock方法的线程如果未能获得锁会立刻返回false,线程可以继续执行其他代码而避免等待,这为程序员提供了更多自由。

                Lock接口还提供了一个newCondition () 方法,它返回一个Condition对象。Condition对象的作用和Object用于线程通知的wait-notify机制相同。

1.4       信号量Semaphore

                有时候我们有多个相同的共享资源可以同时被多个线程使用。我们希望在锁的基础上加上一个计数器,根据资源的个数来初始化这个计数器,每次成功的lock操作都会使计数器的值减去1,只要计数器的值不为零就表示还有资源可以使用,lock操作就能成功。每次unlock操作都会给这个计数器加1。只有当计数器的值为0的时候lock操作才会阻塞当前线程。这就是Java中的信号量Semaphore。

                Semaphore类提供的方法和Lock接口非常类似,当把信号量的资源个数设置成1时,信号量就退化为普通的锁。

1.5       读写锁ReadWriteLock

                对共享资源的访问通常可以分为读取和写入。在有些应用场景中读取可能需要花费较长时间,我们需要使用互斥锁来阻止并发的写入操作以保证数据的一致性。但是对于并发的读取线程其实并不需要使用同步。事实上只有使数据发生变化的操作才需要同步,我们希望有一种方法可以把读取和写入区分开来,读取和写入的操作之间是互斥的,但是多个读取操作可以同时进行,这样可以有效提高读取密集型程序的性能。J2SE5.0提供了ReadWriteLock接口并提供了实现该接口的ReentrantReadWriteLock类:

[java] view plaincopy
 
  1. public interface ReadWriteLock {  
  2.     Lock readLock();  
  3.     Lock writeLock();  
  4. }  

 

从接口方法中不难看出读写锁中包含读锁和写锁。实现类ReentrantReadWriteLock为我们提供了更多便捷的方法来使用读写锁,例如isWriteLocked可以用来检测是否被写锁定。

 

2         线程通知

                除了同步锁,Java Object还有两个可用于线程间通知的同步方法wait和notify。wait和notify必须和synchronized联合使用,一个获得对象锁的线程通过调用对象的wait方法可以暂时放弃锁,把自己阻塞在对象的等待队列中,好让其它等待同一把锁的线程有机会执行。当另外那个获得了锁的线程完成工作后可以调用notify方法来唤醒它继续执行。每次notify调用只能唤醒一个在等待队列中的线程,notifyAll方法可以唤醒所有在该对象等待队列中的线程。

 

3         最小化同步

                线程同步通过让线程顺序进入同步代码块解决了多个线程竞争同一资源而引起的不确定性,但是牺牲了效率,因此为了取得更好地性能,我们需要尽可能少地使用同步。事实上并不是所有的竞争条件都是需要避免的,只有当竞争条件出现在非线程安全的代码段时才会引起问题。

3.1       Atomic 变量

           如果一个操作是原子操作,例如给一个boolean 变量赋值,我们就不需要同步。Java提供了一些Atomic类,使得一些本来不是原子操作(例如自增操作 ++,它包含了取值、加1、赋值三个原子操作)也能够原子执行,从而不需要使用同步。

                Java提供了4个基本的原子类,AtomicInteger, AtomicLong, AtomicBoolean和AtomicReference分别提供针对int,long,boolean,object的原子操作。有意思的是如果你打开JDK的源代码想看看这些原子操作是如何实现的,你会失望地发现代码里面没有使用任何同步或其它技术。如果你在自己的程序中写下同样地代码,那么它们并不是原子的。

3.2       Thread Local 变量

                如果每个线程都有自己私有的成员变量,那么我们也不需要同步。ThreadLocal就是线程的私有变量,每个使用ThreadLocal变量的线程都会有自己独立的ThreadLocal对象,因此就不存在多个线程访问同一个变量的问题。当然由于ThreadLocal变量为线程私有,它也就不可以用于在多个线程间共享状态。

                ThreadLocal类并不神秘,它的实现原理比较简单:每个Thread对象有自己用来存储私有ThreadLocal对象的容器ThreadLocalMap,当某个线程调用ThreadLocal对象的get()方法来 取值的时候,get方法首先会取得当前线程对象,然后取出该线程的ThreadLocalMap,然后检查自己是否已经在map中,如果自己已经存在,直接返回map中的value。如果不存在,把自己作key并初始化一个value加入到当前线程的map中。

[java] view plaincopy
 
  1. public T get() {  
  2.         Thread t = Thread.currentThread();  
  3.         ThreadLocalMap map = getMap(t);  
  4.         if (map != null) {  
  5.             ThreadLocalMap.Entry e = map.getEntry(this);  
  6.             if (e != null)  
  7.                 return (T)e.value;  
  8.         }  
  9.         return setInitialValue();  
  10. }  

 

 

 

4         线程池Thread Pool

 

 

                线程虽然不像进程需要那么多资源,但是它的创建也是有一定开销的,频繁地创建和销毁线程会降低程序的性能;此外应用程序可以创建线程的数量是受机器物理条件制约的,过多的线程会耗尽机器的资源,因此我们在设计程序的时候需要限制并发线程的数量。解决这两个问题的通常做法是使用线程池。线程池在启动的时候一次性初始化若干个线程(也可以根据负载按需启动,也有闲置一定时间的线程会被销毁的策略),然后程序把任务交给线程池去执行而不是直接交给某个线程执行,由线程池给这些任务分配线程。当某个线程执行完一个任务后,线程池会把它设成空闲状态以备下一个任务重用而不是销毁它。线程池在初始化的时候需要指定线程数量上限,当并发任务数量超过线程数量的时候,线程池不会再创建新的线程而是让新任务等待,这样我们就不在需要担心线程数量过多耗尽系统资源了。JDK1.5开始为我们提供了标准的线程池。

4.1       执行器Executor

                Java的线程池实现了以下Executor接口:         

[java] view plaincopy
 
  1. public interface Executor {  
  2.     void execute(Runnable command);  
  3. }  

 

                在多线程编程中,执行器是一种常用的设计模式,它的好处在于提供了一种简单有效的编程模型,我们只需把需要并发处理的工作拆分成独立的任务,然后交给执行器去执行即可而不必关心线程的创建,分配和调度。J2SE5.0主要提供了两种功能的执行器:ThreadPoolExecutor和ScheduledThreadPoolExecutor。ThreadPoolExecutor是基本的线程池实现,ScheduledThreadPoolExecutor在前者基础上增加了任务调度的功能,在把任务交给它时我们可以指定任务的执行时间,而不是立刻执行。

                java.util.concurrent.Executors是用来创建线程池的工厂类,通过它提供的工厂方法,我们可以方便地创建不同特性的线程池。

4.2       Future接口

                Executor接口并没有看起来那么理想,有时候我们执行一个任务是要得到计算的结果,有时候我们需要对任务有更多控制,例如知道它是否完成,或者中途终止它。返回void的execute方法并不能满足我们这些需求。当然我们可以在传入的Runnable类上下功夫来提供类似的功能,但是这样做繁琐且容易出错。既然J2SE为我们提供了线程池的标准实现把我们从多线程编程中解放出来,这些常见的需求当然也会很好地满足。事实上线程池实现了一个更为丰富的ExecutorService接口,它定义了执行任务并返回代表该任务的Future对象的submit方法。

           通过Future接口,我们可以查看已经被提交给线程池执行的任务是否完成,获取执行的结果或者终止任务。

4.3       Runnable 和Callable 接口

                实现了Runnable或Callable接口的类都可以作为任务提交给线程池执行,这两个接口的主要区别在于Callable的call方法有结果返回并且可以抛出异常而Runnable的run方法返回void且不允许有可检查的异常抛出(只能抛runtime exception)。因此如果我们的任务执行后有结果返回,应该使用Callable接口。

 

5         线程和集合类

5.1       线程安全的集合类

  • java.util.Vector
  • java.util.Stack
  • java.util.HashTable
  • java.util.concurrent.ConcurrentHashMap
  • java.util.concurrent.CopyOnWriteArrayList
  • java.util.concurrent.CopyOnWriteArraySet
  • java.util.concurrent.ConcurrentLinkedQueue

5.2       非线程安全集合类

  • java.util.BitSet
  • java.util.HashSet (LinkedHashSet)
  • java.util.TreeSet
  • java.util.HashMap (WeekHashMap, TreeMap, LinkedHashMap, IdentityHashMap)
  • java.util.ArrayList (LinkedList)
  • java.util.PriorityQueue

                这些非线程安全的集合可以通过java.util.Collections.SynchronizedList、SynchronizedMap、SynchronizedSet等方法包装成线程安全的集合。包装器类简单地给被包装集合的各项操作加上了synchronized保护。值得注意的是在使用游标遍历这些包装器集合的时候必须加上额外的synchronized保护,否则会出现问题。

 

[java] view plaincopy
 
  1. List list = Collections.synchronizedList(new ArrayList());  
  2.     ...  
  3. synchronized(list) {  
  4.     Iterator i = list.iterator(); // Must be in synchronized block  
  5.     while (i.hasNext())  
  6.         foo(i.next());  
  7. }  

 

5.3       线程通知集合类

  • java.util.concurrent.ArrayBlockingQueue
  • java.util.concurrent.LinkedBlockingQueue
  • java.util.concurrent.SynchronousQueue
  • java.util.concurrent.PriorityBlockingQueue
  • java.util.concurrent.DelayQueue

                这些集合类都实现了BlockingQueue接口。阻塞队列的特点是当从队列中取出元素时如果队列为空,线程会被阻塞直到队列中有元素被插入。当从队列中插入元素时如果队列已满,线程会被阻塞直到队列中有元素被取出出现空闲空间。阻塞队列可以用来实现生产者消费者模式(Producer/Consumer Pattern) 。

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。