Java并发编程详解之 线程安全和对象共享

一、简单介绍

在我们用框架编程的过程中,经常不用太关注多线程问题,这是因为例如Servlet和RMI(Remote Method Invocation,远程方法调用),RMI使得代码能够调用其他JVM中的运行的对象。这些框架负责解决一些细节问题,例如请求管理、线程创建、负载均衡,并在正确的时刻将请求分发给正确的应用程序组件。编写Servlet的开发人员不需要了解有多少请求在同一时刻要被处理,也不需要了解套接字的输入流或输出流是否被阻塞。当调用Servlet的service方法来响应Web请求时,可以以同步方式来处理这个请求,就好像它是一个单线程程序。这种方式可以简化组件的开发,并缩短掌握这种框架的学习时间,但我们想学好编程,还是需要学习并发编程

1、编程的一些原则

在编写并发应用程序时,一种正确的编程方法就是:首先使代码正确运行,然后再提高代码的速度。即便如此,最好也只是当性能测试结果和应用需求告诉你必须提高性能,以及测量结果表明这种优化在实际环境中确实能带来性能提升时,才进行优化

2、活跃性问题

多线程中提到的活跃性问题,指的就是让线程处理死锁或活锁,就是各个线程阻塞不执行,当某个操作无法继续执行下去时,这样就导致了活跃性问题

平时编程中应该怎样做到这句话?:框架通过在框架线程中调用应用程序代码将并发性引入到程序中。在代码中将不可避免地访问应用程序状态,因此所有访问这些状态的代码路径都必须是线程安全的

3、一般并发错误的修复方法

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:

(1)不在线程之间共享该状态变量

(2)将状态变量修改为不可变的变量

(3)在访问状态变量时使用同步

4、线程安全类和线程安全程序

完全由线程安全类构成的程序并不一定就是线程安全的,而在线程安全类中也可以包含非线程安全的类

二、线程安全性

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的

1、无状态对象

一个无状态的Servlet

import java.math.BigInteger;
import javax.servlet.*;

import net.jcip.annotations.*;

@ThreadSafe
public class StatelessFactorizer extends GenericServlet implements Servlet {

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }
}
大多数Servlet都是无状态的,从而极大地降低了在实现Servlet线程安全性时的复杂性,只有当Servlet在处理请求时需要保存一些信息,线程安全性才会成为一个问题

无状态对象一定是线程安全的

下面的Servlet 就不再是无状态的了

在没有同步的情况下统计已处理请求数量的Servlet(不要这么做)

import java.math.BigInteger;
import javax.servlet.*;
import net.jcip.annotations.*;

@NotThreadSafe
public class UnsafeCountingFactorizer extends GenericServlet implements Servlet {
    private long count = 0;

    public long getCount() {
        return count;
    }

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        ++count;
        encodeIntoResponse(resp, factors);
    }

    void encodeIntoResponse(ServletResponse res, BigInteger[] factors) {
    }

    BigInteger extractFromRequest(ServletRequest req) {
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        // Doesn't really factor
        return new BigInteger[] { i };
    }
}

count是这个对象的状态,虽然递增操作++count是一种紧凑的语法,使其看上去只是一个操作,但是这个操作并非原子的,实际上,它包含了三个独立的操作:读取count的值,将值加1,然后将计算结果写入count。这是一个“读取-修改-写入”的操作序列,并且其结果状态依赖于之前的状态。这样会导致不恰当的执行时序而出现不正确的结果,这种情况称为竞态条件

解决方法:

1、使用AtomicLong类型的变量来统计已处理请求的数量

import java.math.BigInteger;
import java.util.concurrent.atomic.*;
import javax.servlet.*;
import net.jcip.annotations.*;

@ThreadSafe
public class CountingFactorizer extends GenericServlet implements Servlet {
    private final AtomicLong count = new AtomicLong(0);

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

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count.incrementAndGet();
        encodeIntoResponse(resp, factors);
    }
}

在java.tuil.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。

在实际情况中,应尽可能地使用现有的线程安全对象(例如AcomicLong)来管理类的状态)

2、使用加锁:内置锁Synchronized和显式锁Lock

内置锁:

每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁或监视锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路劲退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法

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

下面程序如果内置锁不是可重入的,那么这段代码将发生死锁

public class Widget{
	public synchronized void doSomething(){
		
	}
}
public class LoggingWidget extends Widget{
	public synchronized void doSomething(){
		System.out.println(toString() + " : calling doSomething");
		super.doSomething();
	}
}
在上面代码中,子类改写了父类的synchronized方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。由于Widget和LoggingWidget中的doSomething方法都是synchronized方法,因此每个doSomething方法在执行前都会获取Widget上的锁。然而,如果内置锁不是可重入的,那么在调用super.doSomething时将无法获得Widget上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远无法获得的锁。

对象的内置锁与与其状态之间没有内在的关联。虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。

对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护

虽然Vector等都具有同步操作,但是并不足以确保Vector上复合操作都是原子的,例如

	if(!vector.contains(e)){
		vector.add(e);
	}

虽然contains和add等方法都是原子方法,但在上面这个“如果不存在则添加”的操作中仍然村在竞态条件。虽然synchronized方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还是需要额外的加锁机制

当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁

三、对象的共享

1、加锁与可见性

如右图,当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A看到的变量值在B获得锁后同样可以由B看到

我们可以进一步理解为什么在访问某个共享的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。否则,如果一个线程在未持有正确锁的情况下读某个变量,那么读到的可能是一个失败值

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

2、Volatile变量

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

volatile的使用场景:

仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。

下面是volatile的典型用法

public class CountingSheep {
    volatile boolean asleep;

    void tryToSleep() {
        while (!asleep)
            countSomeSheep();
    }

    void countSomeSheep() {
        // One, two, three...
    }
}

虽然volatile变量很方便,但是存在一些局限性。volatile变量通常用做某个操作完成、发生中断或者状态的标志。但是volatile的语义不足以确保递增操作(count++)的原子性,那些需要依赖于前一个值的变化不适合用volatile

加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性

当且仅当满足以下所有条件时,才应该使用volatile变量:

(1)对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值

(2)该变量不会与其他状态变量一起纳入不变性条件中

(3)在访问变量时不需要加锁

3、发布与逸出

“发布”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。例如将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中

例如下面代码

class Secrets {
    public static Set knownSecrets;

    public void initialize() {
        knownSecrets = new HashSet();
    }
}

上面的knownSecrets就被发布了,这样就导致了逸出。当发布某个对象时,可能会间接发布其他对象,如果将一个Secret对象添加到集合knownSecrets中,那么同样会发布这个对象

再列举两种逸出的例子

1、使内部的可变状态逸出

class UnsafeStates {
    private String[] states = new String[]{
        "AK", "AL" /*...*/
    };

    public String[] getStates() {
        return states;
    }
}

2、隐式地使this引用逸出

public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener(new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
    }
}

注意:内部类也是导致内存泄露的一个方面

安全的对象构造过程

不要在构造过程中使this引用逸出。(下面都是围绕这条展开的)

当从对象的构造函数中发布一个对象时,只是发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此。如果this引用在构造过程中逸出,那么这种对象就被认为是不正确构造

基于上面,在构造过程中使用this引用逸出的一个常见的错误是,在构造函数中启动一个线程。在构造函数中创建线程本身没有错误,但最好不要立即启动它,而是通过一个start或initialize方法来启动。

如果想在构造函数中注册一个时间监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程,避免this引用在构造过程中逸出

例如下面代码:

public class SafeListener {
    private final EventListener listener;

    private SafeListener() {
        listener = new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        };
    }

    public static SafeListener newInstance(EventSource source) {
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener);
        return safe;
    }
}

线程封闭

主要的线程封闭技术有:栈封闭和ThreadLocal类

栈封闭:把变量都声明为局部变量,并且不要使它们逸出

ThreadLocal类:这是维持线程封闭性更规范的一种方法,ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行共享。使用方法如下代码:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class ConnectionDispenser {
    static String DB_URL = "jdbc:mysql://localhost/mydatabase";

    private ThreadLocal connectionHolder
            = new ThreadLocal() {
                public Connection initialValue() {
                    try {
                        return DriverManager.getConnection(DB_URL);
                    } catch (SQLException e) {
                        throw new RuntimeException("Unable to acquire Connection, e");
                    }
                };
            };

    public Connection getConnection() {
        return connectionHolder.get();//当某个线程初次调用ThreadLocal.get方法时,就会调用initialValue来获取初始值。
    }
}


当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术

开发人员经常滥用ThreadLocal,例如将所有全局变量都作为ThreadLocal对象,或者作为一种“隐藏”方法参数的手段。ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心

不变性

不可变对象一定是线程安全的

当满足以下所有条件时,对象才是不可变的

(1)对象创建以后其状态就不能修改

(2)对象的所有域都是final类型

(3)对象是正确创建的(在对象的创建期间,this引用没有逸出)

一个编程过程中的良好习惯

正如“除非需要更高的可见性,否则应将所有的域都声明为私有域”是一个良好的编程习惯,“除非需要某个域是可变的,否则应将其声明为final域”也是一个良好的编程习惯

安全的发布

上面讨论的都是如何确保对象不被发布,现在开始讨论如何安全的发布

安全发布的常用模式:

要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布

(1)在静态初始化函数中初始化一个对象的引用

(2)将对象的引用保存到volatile类型的域或者AtomicReferance对象中

(3)将对象的引用保存到某个正确构造对象的final类型域中

(4)将对象的引用保存到一个由锁保护的域中(如将对象放入Vector或synchronizedList容器)

你可能感兴趣的:(多线程)