欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

<进阶-1> 线程安全性:原子性,可见性,加锁机制

程序员文章站 2022-07-12 18:49:38
...
1.线程安全性
1.1 什么是线程安全性
在构建稳健的并发程序时,必须正确的使用线程和锁。要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)且可变的(Mutable)状态的访问(也就是破坏其中任一个条件都可以保证线程安全,非共享或不可变的状态都不存在线程安全问题)。

“共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。前一篇《基础-2 构建线程安全应用程序》提到过,final且在构造函数完成之后才使用的变量是不会引起并发问题的,因为final不具有可变性。关于为什么要构造函数之后使用才安全后面会提到,因为this逃逸,也就是指在构造函数返回之前其他线程就持有该对象的引用。 调用尚未构造完全的对象的方法可能引发令人疑惑的错误, 因此应该避免this逃逸的发生。this逃逸经常发生在构造函数中启动线程或注册监听器时,所以不要构造函数里调用start启动线程。

从非正式的意义上说,对象的“状态”是指存储在状态变量(如实例或静态域)中的数据。对象的状态可能包括其他依赖对象的域。例如,某个HashMap的状态不仅存储在HashMap对象本身,还存储在许多Map.Entry对象中。在对象的状态中包含了任何可能影响其外部可见行为的数据。如果某个类是无状态的,也就是不包含任何域,也不包含任何对其他类中域的引用,所有临时状态都仅存在于线程栈上的局部变量中,那这个类肯定是线程安全的。无状态对象肯定是线程安全的

一句话总结要注意共享且可变的状态的线程安全问题

当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但“同步”术语还包括volatile类型的变量,显示锁(Explicit Lock)以及原子变量

如果多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:
1) 不在线程之间共享该状态变量。
2) 将状态变量修改为不可变的变量。
3) 在访问状态变量时使用同步。

一句话总结想要并发安全,要么破坏共享且可变的条件,要么使用同步(,synchronized,volatile,lock,atomic variable)来处理。

如果在设计类的时候没有考虑并发访问的情况,那么在采用上述方法时可能需要对设计进行重大修改,因此要修复这个问题可谓是知易行难。如果从一开始就设计一个线程安全的类,那么比在以后再将这个类改为线程安全的类要容易的多。

当设计线程安全的类时,良好的面向对象技术(比如封装状态变量在类内部),不可修改性,以及明晰的不变性规范(不变性条件:状态变量之间的约束关系,比如这两个变量 int[] data; int size; 之间的关系应该是,data中的数据数目=size。当类的不变性条件设计多个状态变量时,那么不变性条件中的每个变量都必须由同一个锁来保护。因此可以在单个原子操作中访问或更新这些变量,从而确保不变性条件不被破坏。)都能起到一定的帮助作用。在某些情况中,良好的面向对象设计技术与实际情况的需求并不一致,这时,可能需要牺牲一些良好的设计原则,以换取性能或者对遗留代码的向后兼容。实际做设计的时候,很多情况是需要一些妥协的,就像牺牲空间换时间牺牲时间换空间一样,设计原则也是一样。有时候,面向对象的抽象和封装会降低程序的性能,但是编写并发应用程序时,一种正确的编程方法是:首先使代码正确运行,然后再提高性能。即便如此,最好也是当性能测试结果和应用需求告诉你必须提高性能,以及测量结果表明这种优化在实际环境确实带来性能提升时,才进行优化。(在编写并发代码时,应该始终遵循这个原则,由于并发错误是非常难以重现和调试的,因此如果只是在某段很少执行的代码路径上获得了性能提升,很可能被程序运行时存在的失败风险而抵消)。

目前,我们看到了“线程安全类”和“线程安全程序”两个术语,二者的含义基本相同,但不能混淆。我们最终的目的是构建“线程安全程序”。但线程安全的程序并不完全由线程安全类构成,完全由线程安全类构成的程序并不一定是线程安全的,HashTable相关的复合操作就是例子。

前面《<基础-2> 构建线程安全应用程序》里讨论了什么是线程安全性。在线程安全性的定义中,最核心的概念就是正确性。当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

1.2 原子性
就像数据库里的定义一样,原子性就是一个操作(可能是需要多步完成的复合操作)不能被打断,一旦开始执行直到执行完其他线程或多核都必须等待。比如”i++”表达式,就不是原子的,汇编后会发现由三条指令(读取,修改,写入)完成,每一条指令完成后都可能被中断。说到原子性,一般会提到可见性,这两者其实没有任何联系,但这两个因素确是同时影响到多线程安全的特性。只具备原子性或可见性并不能保证线程安全(注意synchronized同时保证了原子性和可见性,只保证原子性可能结果并没有同步到主存,其他线程不可见)。可见性跟jvm的内存结构有关系,前面《<基础-2> 构建线程安全应用程序》里给出了jvm内存结构图,各个线程或多核对同一个变量有备份(在线程的工作内存中或核的寄存器中,为了节省IO通信等),导致跟jvm主存中的变量值不一致。这样做的目的是为了提高性能。当然在多线程中就可能造成问题,就要用同步来解决了。
还可以参考:http://www.cnblogs.com/mengyan/archive/2012/08/22/2651575.html

1.2.1 竞态条件(race condition)
在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果不加控制的任意存取肯定会出现错乱,最典型的例子就是银行账户转账。根据各线程访问数据的次序,可能会产生错误的结果,这样一个情况通常称为race condition(中文有的翻译为竞争条件,这里就用原文race condition[b]A race condition is any case where the results can be different depending on the order that processes arrive or are scheduled or depending on the order that specific competing instructions are executed[/b])也就是说race condition通常跟复合操作有关系。

为了避免race condition,必须学习如何同步存取。
最常见的竞态条件类型就是“先检查后执行check-then-act”操作,即通过一个可能失效的观测结果来决定下一步动作。使用“先检查后执行”的一种常见情况就是懒加载。比如:
public class LazyInitRace
{
    private LazyInitRace instance = null;
    
    private LazyInitRace()
    {}
    
    public LazyInitRace getInstance()
    {
        // error:这样无法保证线程安全
        if (instance == null)
        {
            instance = new LazyInitRace();
        }

        return instance;
    }
}

这也是典型的单例类,单例模式有好多实现方式这里不讨论。并发判断instance == null时可能另一个线程已经创建一个实例了,但其他线程没有发现而导致不是单实例。后面我们还会讨论怎样是安全的延迟加载单实例。

“读取-修改-写入”是另一种典型的竞态条件,比如i++。

1.2.2 复合操作
要避免race condition就必须在某个线程修改该变量时,通过某种方式阻止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。这种“先检查后执行”或“读取-修改-写入”等操作都统称为复合操作:包含了一组必须以原子方式执行的操作

这里使用java.util.concurrent(JUC)提供的原子变量AtomicLong来实现线程安全:
public class CountingFactorizer implements Servlet
{
    private final AtomicLong count = new AtomicLong(0);
    public long getCount() 
    {
        return count.get();
    }

    public void service(ServletRequest req, ServletResponse resp)
    {
        BigInteget i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count.incrementAndGet(); // 原子操作。
        eccodeIntoResponse(resp, factors);
    }
}


这里使用了count.incrementAndGet(),这是个原子操作,查看api可以看到该方法的定义:
public final long incrementAndGet() {
        for (;;) {
            long current = get();
            long next = current + 1;
            if (compareAndSet(current, next))
                return next;
        }
    }

无限循环,直到可以线程安全的增长。有时候我们使用其他线程安全类但又没提供这种机制的时候,自己也可以这么做。这里面又有关键的两个方法get和compareAndSet,get简单的返回当前值,但这个当前值是volatile类型的,能获取当前最新的值,compareAndSet就是根据预期值和新值来增长,如果增长成功返回true,否则返回false。

前面看到了“同步”方式包括volatile类型的变量,synchronized,显示锁(Explicit Lock)以及原子变量,解决可见性这4种方式都可以,但解决原子性只能使用后面3种,volatile只能解决可见性。后三种又如何选择呢?(使用java.util.concurrent里的原子类 > synchronized > 锁),后面会详细介绍,在此之前,我们先讨论清楚synchronized,lock是什么怎么用。

1.3 加锁机制
如果一个类里用到多个状态(已经多次说明对象的“状态”是指存储在状态变量(如实例或静态域)中的数据,一定要理解这个概念)变量,即使每个状态变量分别都是原子的,放到一起使用也不能保证整体的原子性。要保持状态一致性,就需要在单个原子操作中(注意是一个同一个原子操作)更新所有有依赖关联关系的状态变量。

从java se5.0开始,有两种机制防止代码块受并发干扰(并发干扰主要是因为对共享数据的影响不是原子操作)。Java语言提供一个synchronized关键字达到这一目的,并且java se 5.0引入了ReentrantLock类。

1.3.1 显式锁 explicit lock
显式锁一般使用ReentrantLock。ReentrantLock实现了Lock接口,并提供了与synchronized相同的互斥性和内存可见性。用ReentrantLock保护代码块的基本结构如下:
ReentrantLock myLock = new ReentrantLock();
myLock.lock();

try
{
    // critical section
}
finally
{
    myLock.unlock(); // 一定要放在finally里,保证锁总能被释放。
}

这一结构控制更加精细。确保任何时刻只有一个线程进入临界区。一旦一个线程*了锁对象,其他任何线程都无法通过lock语句,当其他线程调用lock时,他们被阻塞,直到第一个线程释放锁对象。这一切的前提是多线程获取的是同一个锁对象才会形成阻塞,否则互不影响。

1.3.2 锁的可重入性
锁的可重入就是指线程可以重复获得已经持有的锁。当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而由于内置的锁是可重入的,因此如果某个线程视图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是调用。
锁是可重入的,因为线程可以重复获得已经持有的锁。锁保持一个持有计数(hold count)来跟踪对lock方法的嵌套调用。线程在每一次调用lock都要调用unlock来释放锁。由于这一特性,被一个锁保护的代码可以调用另一个使用相同锁的方法。可重入就是说,一个线程获得共享资源的锁之后,可以重复访问需要该锁的资源而不受锁的限制。

重入进一步提升了加锁行为的封装性,简化了面向对象并发代码的开发。


1.3.3 条件对象 Condition, await/sinalAll
通常,线程进入临界区,却发现在某一条件满足之后它才执行。要使用一个条件对象来管理那些已经获得了一个锁却不能做有用工作的线程。条件对象经常被称为条件变量(conditional variable)。前面说到的复合操作通常是在条件变量处发生,如先判断再操作。
现在模拟银行转账功能,我们避免选择没有足够资金的账户作为转出账户。注意不能使用下面这样的代码:
if (bank.getBanlance(from) >= amount) // 先判断
{
    bank.transfer(from, to, amount); // 再操作
}

当前线程完全有可能在(1)成功的完成测试之后且在调用transfer方法之前被中断,(2)在线程再次运行前,账户余额可能已经低于提款金额。必须保证没有其他线程在本检查余额与转账活动之间修改余额。通过使用锁来完成这一点:
public void transfer(int from, int to, int amount)
{
    bankLock.lock();

    try
    {
        while (accounts[from] < amount)
        {
            // await
        }
    
        // transfer funds
    }
    finally
    {
        bankLock.unlock();
    }
}


现在,当账户中没有足够的余额时,会等待(await)直到另一个线程向账户中注入了资金。

一个锁对象可以有一个或多个相关的条件对象。可以用newCondition()方法获得一个条件对象,习惯上给每一个条件对象命名为可以反映它所表达的条件的名字。
例如:
class Bank
{
    public Bank()
    {
        …
        sufficientFunds = bankLock.newCondition();
    }
    …
    private Condition sufficientFunds;
}

如果线程调用transfer时发现余额不足,它调用sufficientFunds.await();当前线程现在被阻塞了,并放弃了锁(这点很重要,调用Thread.sleep()方法休眠时不会放弃锁)。
等待获得锁的线程和调用await方法的线程存在本质上的不同。一旦一个线程调用await方法,它进入该条件的等待集。当锁可用时,该线程不能马上解除阻塞,相反,它处于阻塞状态,直到另一个线程调用同一条件上的signalAll方法为止。比如当另一个线程转账时,它应该调用sufficientFunds.signalAll();
这一调用重新激活因为这一条件而等待的所有线程。当这些线程从等待集中移出时,他们再次成为可运行的,调度器再次激活他们。同时,他们将试图重新进入该对象。一旦锁成为可用的,他们中的某个将从await调用返回,获得该锁并从被阻塞的地方继续执行。此时,线程应该再次测试条件是否满足,所以await方法的调用通常在循环体中:
while (! Ok to proceed)
{
    condition.await();
}


至关重要的是最终需要某个其他线程调用signalAll方法。当一个线程调用await时,他没有办法重新激活自己,只能寄希望于其他线程,如果没有其他线程调用signal或signalAll或中断该线程,那将导致死锁。经验上讲,应该在对象的状态变化了,有利于等待线程的方向改变时调用signalAll。
注意:signallAll不会立即激活一个等待线程。它仅仅解除等待线程的阻塞,以便这些线程可以在当前线程退出同步方法后,再次通过竞争实现对对象的访问。

1.3.4 内置锁 synchronized
前面介绍了ReentrantLock和Condition,这向程序设计人员提供了高度的*控制。但大多数情况下,不需要这样的控制,并且可以使用一种嵌入到java语言内部的机制。java1.0开始,java中的每一个对象都有一个内部锁。
Java提供了一种内置的锁机制来支持原子性:同步代码块(synchronized block)。同步代码块包括两部分:一是对被当做的对象的引用,也就是放在synchronized(obj){…}里的obj; 二是由这个锁保护的代码块
每个java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)(之前本人一直想弄明白内置锁和监视器锁的区别,后来查了很多才知道,两者是一样的,只是别名而已)。线程在进入同步代码块之前会自动获得锁,并且退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。Java的内置锁相当于一种互斥体,这意味着最多只有一个线程能持有这种锁。

如果一个方法用synchronized关键字声明,那么对象的内部锁将保护整个方法。换句话说:
public synchronized void method()
{
   …
}
等价于:
public void method()
{
    this.intrinsicLock.lock();

    try
    {
       …
    }
    finally
    {
       this.intrinsicLock.unlock();
    }
}


内部对象锁只有一个相关条件(ReentrantLock可以有一个或多个Condition),调用对象的wait/notifyAll(这两个是从Object对象继承过来的方法)等价于intrinsicCondition.await()和intrinsicCondition.signalAll()。

可以看到,使用synchronized关键字来编写代码简洁的多。当然,必须了解每一个对象有一个内部锁,并且该锁有一个内部条件。由内部锁来管理那些视图进入synchronized方法的线程,由内部条件来管理那些调用wait的线程。

内部锁和内部条件存在一些局限,包括:
1) 不能中断一个正在试图获得锁的线程。
2) 试图获得锁时不能设定超时。
3) 每个锁仅有单一的条件,可能是不够的。

那在实际中应该怎样选择,使用synchronized还是Lock加Condition?下面是一些建议
1)最好两者都不使用。在许多情况下可以使用java.util.concurrent(JUC)包中的某一种机制,它会为你处理所有的锁。JUC里的机制后面会介绍。
2)如果synchronized关键字适合你的程序请尽量使用,这样可以减少编写的代码数量,减少出错的几率。synchronized有几种使用方法,尽量使用同步块,其次是同步方法,总之是让同步的范围尽量小
3)如果特别需要Lock/Condition结构提供的独有特性时,如中断,超时,多个条件等,才使用Lock/Condition。

一定注意,
(1)notify/notifyAll/wait只能在同步方法或同步块内部使用,且调用这几个方法的对象要跟synchronized锁住的对象是同一个对象,否则会抛出
IllegalMonitorStateException - if the current thread is not the owner of this object's monitor.
也就是说synchronized(obj),那一定要是obj.wait()或obj.notifyAll(),如果锁住的是类实例,那可以直接调用wait()或notifyAll().
(2)wait要在循环里调用,因为虽然再次获得了执行权仍要要再次检查条件是否满足。

例子:
比如Servlet要实现因数分解的操作,使用synchronized同步整个因数分解的方法,这样Servlet就是线程安全的。但是,这种方法过于极端,客户端无法同时使用因数分解Servlet,服务的响应非常低,几乎变成了单线程,synchronized的范围太大了,如果分解需要很长时间,那问题就很严重。又但是,至少这样没有线程安全问题,只是性能问题。后面会逐步讲到更好的写法,因此这仍然是不推荐的写法。
public class SynchronizedFactorizer implements Servlet
{
    // 此servlet实现因数分解,lastNumber是缓存上次被因数分解的那个数,lastFactors
    // 是因数分解的结果。这里是种不规范的缓存。要求两者在多线程下必须对应。
    private BigInteger lastNumber; 
    private BigInteger[] lastFactors;

    public synchronized void service(ServletRequest req, ServletResponse resp)
    {
        BigInteger i = extractFromRequest(req);
    
        if (i.equals(lastNumber))
        {
            encodeIntoResponse(resp, lastFactors);
        }
        else
        {
            BigInteger[] factors = factor(i);
            lastNumber = i;
            lastFactors = factors;
            encodeIntoResponse(resp, factors);
        }
    }  
}


关于synchronized用在变量,static变量,类上,可以参考:
http://developer.51cto.com/art/201104/255305.htm

1.3.5 同步阻塞
有时程序员用一个对象的锁来实现额外的原子操作。实际上称为客户端锁定(client-side locking),客户端锁定是脆弱的,不推荐使用。例如,Vector类,它的方法单独都是同步的,现在假定Vector<Double>里存储银行余额。如果转账方法transfer实现:
public void transfer(Vector<Double> accounts, int from, int to, int amount)
{
    accounts.set(from, accounts.get(from) - amount);
    accounts.set(to, accounts.get(to) + amount);
}

Vector类的get和set方法都是同步的,但是这对于我们没有什么帮助,组合操作起来并不是同步的。然后我们可以修改方法,使用锁来同步:
public void transfer(Vector<Double> accounts, int from, int to, int amount)
{
    synchronized(accounts)
    {
        accounts.set(from, accounts.get(from) - amount);
        accounts.set(to, accounts.get(to) + amount);
    }
}

这个方法可以工作,但是它完全依赖于一个事实,Vector类对自己的所有可修改方法都使用内部锁。然而Vector类的文档并没有给出这样的承诺。可见,客户端锁定是非常脆弱的,不推荐使用,虽然实际中我们这样使用的很多。实际使用时,我们应该先思考下有没有其他方式实现同步。

1.3.6 锁测试和超时
线程在调用lock方法来获得另一个线程所持有的锁时,很可能发生阻塞,lock方法不能被中断。如果一个线程在等待获得一个锁时被中断,中断线程在获得锁之前一直处在阻塞状态。如果出现死锁,那么lock方法就无法终止。所以应该更加谨慎的申请锁。tryLock方法试图申请一个锁,在成功获得锁后返回true,否则立即返回false,而且线程可以立即离开去做其他事:
if(myLock.tryLock())
{
    try
    {
        …
    }
    finally
    {
        myLock.unlock();
    }
}
else
{
    // do something else
}

tryLock无论获得还是没获得锁都会立即返回。还可以带上超时参数,阻塞时间不超过设定。这也是前面提到的显示锁ReentrantLock的优势。

1.3.7 读写锁
Java.util.concurrent.locks包定义两个锁类。ReentrantLock和ReentrantReadWriteLock。如果很多线程从一个数据结构读取数据而很少线程修改其中数据的话,后者十分有用。
下面是使用读写锁的必要步骤:
1)构造对象
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

2)抽取读锁和写锁
private Lock readLock = rwLock.readLock();
private Lock writeLock = rwLock,writeLock();

3)对所有访问者加读锁
public double getTotalBalance()
{
    readLock.lock();
    try
    {…}
    finally
    { 
       readLock.unlock();
    }
}

4)对所有修改者加写锁
public void transfer(…)
{
    writeLock.lock();
    try
    {…}
    finally
    {
        writeLock.unlock();
    }
}


1.3.8 volatile域
有时,仅仅为了读写一个或两个实例域就使用同步显得开销过大了。但如果不采取任何措施,出错的可能性很大:
1)多处理器的计算机能暂时在寄存器或本地内存缓冲区保存内存中的值。结果是,运行在不同处理器上的线程可能在同一个内存位置取到不同的值。
2)编译器可能改变指令执行的顺序以使吞吐量最大化。这种顺序上的变化不会改变代码语义,但是编译器假定内存的值仅仅在代码中有显式的修改指令才会改变。然而,内存的值可以被另一个线程改变。

关于jvm的内存模型前面《基础-2 构建线程安全应用程序》介绍过。
Brian Goetz给出了“同步格言”:如果向一个变量写入值,而这个变量接下来可能被另一个线程读取,或者从一个变量读值,而这个变量可能是之前另一个线程写入的,此时必须使用同步。
volatile关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。会不在缓存中保存该值,各个线程看到的值都是最新修改的,解决了可见性的问题。但并不能保证原子性。如volatile Boolean done;  使用时这样使用:done = !done; 这是不能确保改变域中的值的,因为done也可能被其他线程修改了。关于volatile的使用场景在《基础-2 构建线程安全应用程序》介绍过了。此时,可以使用AtomicBoolean解决。这个类的get和set方法是原子的,该实现使用有效的机器指令实现,在不使用锁的情况下确保原子性,且十分高效。

总之,在以下3个条件之一下,域的并发访问是安全的:
1)域是final的,并且在构造器调用完成之后被访问。显然,不能再被修改的域肯定线程安全,就像String。
2)对域的访问由公有的锁保护。
3)域是volatile的。但这个有使用场景限制。


后面说到对象的发布时还会详细介绍安全发布对象的常用方法。

1.4 用锁来保护状态
由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问,只要始终遵循这些协议,就能确保状态的一致性。访问共享状态的复合操作,如“读取-修改-写入”或“先检查后执行”都必须是原子操作以避免产生静态条件。然而,仅仅将复合操作封装到一个同步代码块中是不够的,如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步,而且,都要使用同一个锁。无论是写入还是读取都要锁。

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步使得在该对象上不会发生并发访问。在许多线程安全类中都使用了这种模式,比如Vector, Hashtable等。注意自己写类似的类时,不要随便的给可变共享状态加上get方法,这是程序员经常犯的错误,让外部直接获得这个内部状态很明显存在安全隐患,内部封装锁的再好都没用。并非所有数据都需要锁的保护,前面说过,只有被多个线程同时访问的可变状态才需要锁来保护,也就是两个条件:共享+可变的状态才会可能发生并发问题

如果同步可以避免竞态条件问题,那么为什么不在每个方法声明时都使用关键字synchronized?事实上,滥用synchronized可能导致过多的同步,导致性能问题。另外还可能导致活跃性问题(也就是死锁,饿死等问题,后面还会说到)。

1.5 性能
修改下1.3.1节不推荐的程序。

<进阶-1> 线程安全性:原子性,可见性,加锁机制
            
    
    博客分类: java 并行编程 原子性可见性加锁机制waitnotify 

修改后,做到了简单性和并发性的平衡。使用锁时一定要在保证并发安全的同时锁住的代码尽量小。而且使用锁时,应该清楚代码块中实现的功能,以及执行该代码是否需要很长的时间(比如网络IO),如果执行某个可能阻塞的操作或持有锁的时间过长,一定不要独占锁。

主要参考资料:
《Java 并发编程实战》
《Java核心技术1》
  • <进阶-1> 线程安全性:原子性,可见性,加锁机制
            
    
    博客分类: java 并行编程 原子性可见性加锁机制waitnotify 
  • 大小: 122.2 KB