并发编程实战 - 基础部分[线程安全性、对象的共享]

--------------------------------------线程安全性------------------------------------

1、Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但“同步”这个术语还包括volatile类型的变量,显式锁和原子变量。

2、线程安全的程序是否完全由线程安全类构成?答案是否定的。完全由线程安全类构成的程序并不一定是线程安全的,而在线程安全类中也可以包含非线程安全的类。

if(!vector.contains(element))
    vector.add(element);
虽然contains和add等方法都是原子方法,当是上面的这个操作仍然存在竞态条件[5]。

3、在线程安全性的定义中,最核心的概念就是正确性。即当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是共享的和可变的状态变量。

4、无状态的对象是线程安全的。所谓的无状态是指既不包含域,也不包含任何对其他类中的域的引用,例如直接实现一个Servelet的子类

public class MyServelet implement Servelet{ 
@Override public void service(ServeletRequest req,ServeletResponse response){
......
}
}

,大多数的Servlet是无状态的,从而极大的降低了在实现Servelet线程安全性时的复杂性,只有当Servelet在处理请求时,需要保存一些信息,线程安全性才会成为一个问题。这里说一下什么是对象的状态:存储在状态变量(例如实例或静态域)中的数据。

5、在多线程编程中,由于不恰当的执行时序而导致的不正确的执行结果被称为“竞态条件”。当某个计算的正确性取决于多个线程的交替执行时序时,那么就容易发生竞态条件。最常见的竞态条件类型就是“先检查后执行”操作,即【if(..)...;】可能通过一个已经失效的结果决定决定下一步的动作。要避免竞态条件,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量。

6、原子操作:对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是以原子的方式执行的操作。

7、复合操作:我们将“先检查后执行”以及“读取-修改-写入(例如递增运算)”等操作成为复合操作。为了确保复合操作的线程安全性,必须以原子的方式执行,即:要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

public class CountingFactorizer implements Servlet{
        private final AtomicLong count = new AtomicLong();

        public long getCount(){
            return count.get();
        }

        @Override 
        public void service(ServeletRequest req,ServeletResponse response){
            ......
            count.incrementAndGet();
            ......
        }
    }

在实际情况中,应尽可能地使用现有的线程安全对象(例如AtomicLong)来管理类的状态。如上,在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象管理,那么该类也是线程安全的。

8、内置锁:每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁或监视锁。

Java提供了一种内置的锁机制来支持原子性:同步代码块。同步代码块包含两个部分:一个作为锁的对象引用,一个作为有这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象,静态的synchronized方法以class对象作为锁。

    synchronized (lock){
        ......
    }
线程在进入同步代码块之前就会自动的获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制途径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
9、重入

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个由它自己持有的锁,那么这个请求就会成功。

  public class Widget{
        public synchronized void doSomething(){
            ......
        }
        
    }
    
    public class LoggingWidget extends Widget{
        public synchronized void doSomething(){
            ......
            super.doSomething();
        }
    }
如果没有可重入的锁,那么上述这段代码将产生死锁。

重入的一种实现方式是为每一个锁关联一个计数值和一个所有者线程。当计数值是0的时候,意味着该锁未被任何线程持有。当线程请求一个未被持有的锁时,JVM会记下锁的持有者,并将计数值置为1。如果同一个线程再次请求获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减,当计数值为0的时候,则这个锁将被释放。

10、用锁来保护状态

如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,都要使用同一个锁。一种常见的错误是认为只有在写入共享变量时,才需要使用同步,然而事实并非如此。对于可能被多个线程同时访问的可变状态变量,在访问它的时候都需要持有同一个锁。在这种情况下,我们称状态变量时这个锁保护的。

11、关于Synchronized关键字的使用建议

如果只是简单的将每个需要同步的方法添加一个synchronized关键字[例如public synchronized void method(){.....}],并且如果同步方法中包含耗时的操作,那么代码的执行性能将是非常的糟糕的。幸运的是,我们可以通过缩小同步代码块的作用范围,容易的做到既保证并发性又能保证安全性。即尽量将不影响共享状态且执行时间较长的操作从代码块中分离出去。

 public class CacheFactorizer implements Servlet{
      
      @GuardedBy("this") private BigInteger lastNumver;//由内置锁来保护
      @GuardedBy("this") private BigInteger[] lastFactors;
      @GuardedBy("this") private long hits;
      @GuardedBy("this") private long cacheHits;

      public synchronized long getHits() {
          return hits;
      }

      public synchronized void setCacheHits(long cacheHits) {
          this.cacheHits = cacheHits;
      }
      
      public void service(ServletRequest req,ServletResponse resp){
          BigInteger i = extractFromRequest(req);
          BigInteger[] factors = null;
          synchronized (this){
              ++hits;
              if (i.equals(lastNumver)){
                  ++cacheHits;
                  factors = lastFactors.clone();
              }
          }
          
          if (factors==null){
              factors = factor(i);
              synchronized (this){
                  lastNumver=i;
                  lastFactors = factors.clone();
              }
          }
          encodeIntoResponse(resp,factors);
      }
  }

--------------------------------------对象的共享------------------------------------

11、同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的错误是认为synchronized只能用于实现原子性或确定“临界区”。同步还有另外一个重要的方面:内存可见性,即一个线程修改了对象状态后,其他线程能够看到发生的状态变化。

同步   ---->    原子性、内存可见性

 volatile  ---->    内存可见性

通常,我们无法确保执行读操作的线程能够适时地看到其他线程写入的值,有时甚至是根本不可能的。为了保证多个线程之间对内存写入操作的可见性,必须使用同步机制。

public class NoVisibility {

	private static boolean ready;
	private static int number;
	
	private static class ReadyThread2 extends Thread{
		
		public void run(){
		while(!ready){
			Thread.yield();
		}	
		System.out.println(number);
		}
	}
	
	public static void main(String[] args) {
       new ReadyThread2().start();
       number = 42;
       ready = true;
	}

}
虽然NoVisibility看起来可能会输出42,但事实上很可能是0,或者根本无法终止。因为没有使用同步机制,因此无法保证主线程上写入的ready和number值对于读线程来说是可见的。
NoVisibility可能会持续循环下去,因为读线程可能永远看不到ready的值,至于为什么可能会输出0呢?这是因为“重排序”,只要在某个线程中无法检测到重排序的情况,那么就无法确保线程中的操作将按照程序中指定的顺序来执行。

【在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得到正确的结论】

关于重排序:

在编译器中生成的指令顺序,可以和源码中的顺序不同,此外编译器还会把变量保存在寄存器而不是内存中;处理器可以采用乱序或并行等方式来执行指令。缓存可能会改变将写入变量提交到主内存的次序;而且,保存在处理器本地缓存中的值,对于其他处理器是不可见的。

因此这些因素都会使得一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作似乎在乱序执行。 -- 如果没有使用正确的同步。

在缺乏同步的情况下,各种是操作延迟或者看似乱序执行的不同原因,都可以归为重排序。

Java语言规范要求JVM在线程中维护一种类似串行的语义:只要程序的最终结果与在严格串行环境中执行的结果相同,那么上述所有操作都是允许的。近些年,计算性能的提升在很大程度上要归功于这些重排序措施。

同步将限制编译器、运行时和硬件对内存操作的重排序方式,从而在实施重排序时不会破坏JMM(Java内存模型)提供的可见性保证。

幸运的是,有一种简单的方法能避免这些复杂的问题:只要有数据在多个线程之间共享,就使用正确的同步。

12、失效数据

public class SynchronizedInteger{
		
	@GuardedBy("this")	private int value;
		
		public synchronized int get(){
			return value;
		}
		
		public synchronized void set(int value){
			this.value = value;
		}
	}
仅对set方法进行同步是不够的,调用get方法的线程仍然可能会看见失效的数据。

13、非原子的64位操作

最低安全性:当线程在没有同步的情况下,可能会得到一个失效值,但至少这个值是由之前的某个线程设置的,而不是一个随机值。这种安全性保证也被称为最低安全性。

最低安全性适用于绝大多数变量,但存在一个例外:非volatile类型的64位数值变量。Java内存模型要求,变量的读取和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作和写操作分解为两个32操作,因此在多线程中使用共享且可变的long和double等类型的变量时不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。

14、加锁与可见性

在访问某个共享且可变的变量时,要求所有的线程在同一个锁上同步【注意是同一个锁】,就是为了确保某个线程写入该变量的值对于其他线程来说是可见的。否则,如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的可能是一个失效的值。

加锁的含义不仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值。所有执行读操作或者写操作的线程都必须在同一个锁上同步。

15、volatile变量

Java提供了一种稍弱的同步机制,即volatile变量,用来确定将变量的更新操作通知到其他线程。把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

建议:不要过度依赖volatile变量提供的可见性,如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。

加锁机制既可以确保原子性也可以确保内存可见性,而volatile只能确保可见性。

当且仅当满足以下所有条件时,才应该使用volatile变量:
a、当变量的写入不依赖于变量的当前值,或者你能确保只有单个线程更新变量的值;
b、该变量不会与其他状态一起纳入不变性条件中;
c、在访问变量时不需要加锁。
volatile变量的正确使用方式包括:确保它们自身状态的可见性,确保它们所引用的对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生。volatile变量通常用作某个操作完成、发生中断或者状态的标志。

16、发布与逸出
发布:使对象能够在当前作用域之外的代码访问;

逸出:某个不应该发布的对象被发布了。

17、线程封闭

局部变量、ThreadLocal[可以将ThreadLocal视为包含了Map对象]


你可能感兴趣的:(Java并发编程实战)