多线程(2)同步(ReentrantLock,Synchronized,Volatile,死锁)

本文深入探讨Java多线程的高级主题,包括竞争条件、锁机制、synchronized关键字、Volatile域、死锁及其应对策略。通过实例解析,帮助读者理解如何在多线程环境中避免常见陷阱,实现高效安全的并发编程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

一、竞争条件

二、锁(ReentrantLock)

1、锁对象

2、条件对象

三、synchronized关键字

1、监视器概念

2、同步方法

3、同步块(synchronized block)

四、Volatile域

1、volatile关键字

2、final变量

3、原子性

五、死锁及如何应对

1、死锁

2、线程局部变量(避免共享变量)

3、锁测试与超时

4、读/写锁

5、为什么弃用stop和suspend方法


一、竞争条件

在大多数的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。根据各线程访问数据的次序,可能会产生讹误的对象。这种情况成为竞争条件。

例如:

accounts[to] += amount;

这个操作不是原子操作,该指令可能被处理如下:

(1)将accounts[to]加载到寄存器

(2)增加amount

(3)将结果写回accounts[to]

假如线程1执行了步骤1和2,然后被剥夺了运行权,线程2执行了步骤1、2和3后,线程1被唤醒执行步骤3,导致结果不正确。

增值命令是由几条指令组成的,执行它们的线程可以在任何一条指令点上被中断。

有两种机制防止代码块受到并发访问的干扰:synchronized关键字ReentrantLock类

二、锁(ReentrantLock)

1、锁对象

用ReentrantLock保护代码块的基本结构:

myLock.lock();
try
{
    //临界区
    critical section
}
finally
{
    myLock.unlock();
}

把解锁操作放在finally子句之内是至关重要的。如果在临界区的代码抛出异常,锁必须被释放。否则其他线程将永远阻塞。 

ps:如果使用锁,就不能使用带资源的try语句。首先,解锁方法名不是close。不过,即使将它重命名,带资源的try语句也无法正常工作。它的首部希望声明一个新变量。但是如果使用一个锁,你可能想使用多个线程共享的那个变量(而不是新变量)。

pps:带资源的try语句,它允许在try关键字后紧跟一对圆括号,圆括号可以声明、初始化一个或多个资源(此处的资源是指那些必须在程序结束时显式关闭的资源,比如数据库连接,网络连接等),try-with-resources 是一个定义了一个或多个资源的try 声明,try语句在该语句结束时自动关闭这些资源。try-with-resources确保每一个资源在处理完成后都会被关闭。这些资源必须实现AutoCloseable或者Closeable接口,实现这两个接口就必须实现close() 方法。

银行类例子:

package com.thread.bank;
 
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
 
//银行类提供各种基础的服务
public class Bank {
	private Lock bankLock = new ReentrantLock();
        ...
 
	public void transfer(int from, int to, double amount) {
		bankLock.lock();
		try {
			System.out.println(Thread.currentThread());
			accounts[from] -= amount;
			System.out.printf("%10.2f from %d to %d", amount, from, to);
			accounts[to] += amount;
			System.out.printf("Total Balance:%10.2f%n", getTotalBalance());
		} finally {
			bankLock.unlock();
		}
	}
}

 每一个Bank对象有自己的ReentrantLock对象。如果两个线程试图访问同一个Bank对象,那么锁以串行方式提供服务。但是,如果两个线程访问不同的Bank对象,每一个线程得到不同的锁对象,两个线程都不会发生阻塞。

ReentrantLock是可重入锁,线程可以重复获得已经持有的锁。锁保持一个持有计数来跟踪对lock方法的嵌套使用。被一个锁保护的代码可以调用另一个使用相同锁的方法。

ps:注意临界区的代码,不要因为异常的抛出而跳出了临界区。如果再临界区代码结束之前抛出了异常,finally子句将释放锁,但可能会使对象处于一种受损状态。

2、条件对象

通常,线程进入临界区,却发现在某一个条件满足之后它才能执行。要使用一个条件对象(也称为条件变量)来管理那些已经获得了一个锁但是却不能做有用工作的线程。

例如银行,要避免选择没有足够资金的账户作为转出账户,注意不能使用下面这样的代码:

if(bank.getBalance(from)>=account)
    //有可能在这里中断
     bank.transfer(from,to,amount);

因为当前线程完全有可能在完成测试、调用transfer方法之前被中断,当线程再次运行之前,账户余额可能已经低于提款金额。通过使用锁来保护检查与转账动作来做到这一点:

public void transfer(int from, int to, double amount) {
		bankLock.lock();
		try {
			while(accounts[from]<amount){
				//wait
				...
			}
			//transfer funds
			...
		} finally {
			bankLock.unlock();
		}
	}

当余额不足时,等待直到另一个线程向账户中注入资金。但是,由于这一线程刚刚获得对banklock的排它性访问,因此别的线程没有进行存款操作的机会。这就是使用条件对象的原因。

一个锁对象可以有一个或多个相关的条件对象。你可以用newCondition方法获得一个条件对象。习惯上给每一个条件对象命名为可以反映它所表达的条件的名字。例如,在此设置一个条件对象来表达“余额充足”条件。

class Bank{
	private Condition sufficientFunds;
	...
	public Bank(){
		...
		sufficientFunds = bankLock.newCondition();
	}
}

如果transfer方法发现余额不足,它调用sufficientFunds.await()。

当前线程现在被阻塞了,并放弃了锁。我们希望这样可以使得另一个线程可以进行增加账户余额的操作。

等待获得锁的线程和调用await方法的线程存在本质上的不同。一旦一个线程调用await方法,它进入该条件的等待集。当锁可用时,该线程不能马上解除阻塞。相反,它处于阻塞状态,直到另一个线程调用同一条件上的signalAll方法时为止。

当另一个线程转账时,它应该调用sufficientFunds.signalAll();

 这一调用重新激活因为这一条件而等待的所有线程。当这些线程从等待集当中移出时,它们再次成为可运行的,调度器将再次激活它们。同时,它们将试图重新进入该对象。一旦锁成为可用,它们中的某个将从await调用返回,获得该锁并从阻塞的地方继续执行。

此时,线程应该再次测试该条件。由于无法确保该条件被满足——signalAll方法仅仅是通知正在等待的线程:此时有可能已经满足条件,值得再次去检测该条件。

至关重要的是最终需要某个其他线程调用signalAll方法。当一个线程调用await方法时,它没办法重新激活自身。它寄希望与其他线程。如果没有其他线程来重新激活等待的线程,它将永远不再运行了。这将导致死锁(deadlock)现象。

应在对象的状态有利于等待线程的方向改变时调用signaAll。

public void transfer(int from, int to, double amount) {
		bankLock.lock();
		try {
			while(accounts[from]<amount){
				sufficientFunds.await();
                        //transfer funds
                        ...
                        suficientFunds.signalAll();
			}
		} finally {
			bankLock.unlock();
		}
	}

signalAll方法不会立即激活一个等待线程,它仅仅解除等待线程的阻塞,以便这些线程可以在当前线程退出同步方法之后,通过竞争实现对对象的访问。

signal方法是随机解除等待集中某个线程的阻塞状态,但存在带来死锁的危险。

当一个线程拥有某个条件的锁时,它仅仅可以在该条件上调用await、signalAll或signal方法。

锁和条件的关键之处:

(1)锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码。

(2)锁可以管理试图进入被保护代码段的线程。

(3)锁可以拥有一个或多个相关的条件对象。

(4)每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。

三、synchronized关键字

1、监视器概念

锁和条件是线程同步的强大工具,但不是面向对象的。而监视器(monitor),可以使程序员不需要考虑如何加锁就保证多线程的安全性。

监视器和锁的关系:锁为实现监视器提供必要的支持。

监视器具有如下特性:

(1)监视器是只包含私有域的类

(2)每个监视器类的对象有一个相关的锁

(3)使用该锁对所有的方法进行加锁(Java:所有方法是synchronized的),调用时自动获得对象锁,返回时自动释放该锁

(4)该锁可以有任意多个相关条件

Java设计者以不是很精确的方式采用了监视器概念:

(1)Java中每一个对象都有一个内部锁和内部条件

(2)如果一个方法调用synchronized声明,那么该方法就如同一个监视器方法(自动加锁放锁)

(3)通过wait/notifyAll/notify来访问条件变量

然而,在下面三个方面Java对象不同于监视器,使线程安全性下降:

(1)域不要求是private的

(2)方法不要求必须是synchronized的

(3)内部锁对客户是可用的

2、同步方法

Java中的每一个对象都有一个内部锁。如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。

换句话说,

public synchronized void method()
{
    method body
}

等价于

public void method()
{
    this.intrinsicLock.lock();
    try
    {
        method body
    }
    finally{ this. intrinsicLock.unlock(); }
}

内部对象锁只有一个相关条件。wait方法添加一个线程到等待集中,notifyAll/notify方法解除等待线程的阻塞状态。

ps:wait、notifyAll以及notify方法是Object类的final方法。Condition方法必须命名为await、signalAll和signal避免冲突。

例如bank类:

class Bank{
    private double[] accounts;
    //改用synchronized关键字,调用内部锁
    public synchronized void transfer(int from, int to, int amount){
        while(account[from] < amount){
            wait();
        }
        accounts[from] -= amount;
        accounts[to] += amount;
        notifyAll();
    } //synchronized标注的方法执行完毕,内部锁自动释放

    public synchronized double getTotalBalance(){...}
}

理解synchronized的关键是了解每一个对象有一个内部锁,并且该锁有一个内部条件。由锁来管理那些试图进入synchronized方法的线程,由条件来管理那些调用wait的线程。

将静态方法声明为synchronized也是合法的(等价于对类加锁)。如果调用这种方法,该方法获得相关的类对象的内部锁。

内部锁和条件存在一定局限:

(1)不能中断一个正在试图获得锁的线程

(2)试图获得锁时不能设定超时

(3)每个锁仅有单一的条件,可能是不够的

在代码中应该使用Lock和Condition还是同步方法?

(1)最好既不使用Lock和Condition也不使用synchronized关键字,可以考虑使用java.util.concurrent包中的一种机制,它会为你处理所有的加锁(例如阻塞队列)

(2)如果synchronized关键字适合你的程序,那么请尽量使用它,这样可以减少代码量和出错的概率

(3)如果特别需要Lock/Condition结构提供的独有特性时,才使用Lock/Condition

3、同步块(synchronized block)

每个Java对象有一个锁,可以通过调用同步方法获得Java对象锁。还有一种机制可以获得锁,即通过进入一个同步阻塞(同步块)。当线程进入如下形式的阻塞:

1 synchronized(obj){     // obj作为该同步块的锁对象,只有持有该对象的锁才能进入代码块
2     critical section
3 }

于是它获得obj的锁。

例如bank类,会发现“特殊的”锁:

public class Bank{
    private double[] accounts;
    private Object lock = new Object(); //专门取其内部锁辅助我们做同步操作
    ...
    public void transfer(int from, int to, int amount){
        synchronized(lock){    //取其对象锁,进入代码块
            accounts[from] -= amount;
            accounts[to] += amount;
        } //释放对象锁
    }
}

在此,lock对象被创建仅仅是用来使用每个Java对象持有的锁。

有时程序员使用一个对象的锁来实现额外的原子操作,成为客户端锁定(client-side locking)。例如: 

/*
    显然该方法不是原子操作,线程并发访问时将存在同步问题
*/
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);
    ...
}

--------------------------

/*
    使用同步块使该方法关键操作变成原子操作
*/
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类的文档没有给出这样的承诺。所以,客户端锁定是非常脆弱的,通常不推荐使用。

四、Volatile域

1、volatile关键字

有时,仅仅为了读写一个或两个实例域就使用同步,开销过大。(写后读必须同步。)

volatile关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。

详细介绍:https://blog.youkuaiyun.com/wyplj2015/article/details/78584569

注意:Volatile变量不能提供原子性。例如,方法:

public void flipDone() { done = !done } //not atomic

不能确保翻转域中的值。

2、final变量

除非使用锁或volatile修饰符,否则无法从多个线程安全地读取一个域。

但将一个域声明为final时,也可以安全地访问这个共享域。例如:

final Map<String,Double> accounts = new HashMap();

其他线程在构造函数完成构造之后才能看到这个accounts变量。如果不使用final,其他线程看到的可能是null,而不是新构造的HashMap。(对这个映射表的操作不是线程安全的,多线程读写需要同步。)

3、原子性

假设对共享变量除了赋值(原子性操作)之外并不完成其他操作,那么可以将这些共享变量声明为volatile。(这里的赋值是一个简单赋值,而不是读取 增加某个数再赋值 ,所以它是原子操作! 是 x = 1 而不是 x = x +1)

在java.util.concurrent.atomic包中有很多类使用了高效的机器指令(而不是锁)来保证其他操作的原子性。 例如:AtommicInteger类提供了方法incrementAndGet 和 decrementAndGet,它们分别以原子方式将一个整数自增或自减。(可以安全地使用AtommicInteger作为共享计数器而无须同步。)

五、死锁及如何应对

死锁的四个必要条件以及处理策略

1、死锁

锁和条件不能解决多线程中的所有问题。考虑下面的情况:

账户1余额:200

账户2余额:300

线程1:账户1→账户2(300)

线程2:账户2→账户1(400)

因为线程1和线程2的金额都不足以进行转账,所以两个线程都阻塞了,这种状态就叫死锁(deadlock)。

将signalAll方法换为signal方法,很容易导致死锁。因为,signal方法仅仅对一个线程解锁,如果该线程不能运行,所有的线程可能都被阻塞。

Java语言中没有任何东西可以避免或打破死锁,必须仔细设计程序,以确保不会出现死锁。

2、ThreadLocal(避免共享变量)

有时可能要避免共享变量,使用ThreadLocal(线程局部变量)辅助类为各个线程提供各自的实例。

例如,SimpleDateFormat类不是线程安全的。假设有一个静态变量:

public static final SimpleDateFormat dataFormat = new SimpleDateFormat("yyyy-MM-dd");

如果两个线程都执行以下操作:

String dateStamp = dateFormat.format(new Date());

结果很可能会混乱,因为dateFormat使用内部数据结构可能会被并发的访问破坏。当然可以使用同步,但开销很大;或者也可以在需要时构造一个局部SimoleDateFormat对象,不过这也太浪费了。

要为每一个线程构造一个实例,可以使用如下代码:

public static final ThreadLocal<SimpleDateFormat> dateFormat = 
    new ThreadLocal<SimpleDateFormat>()
    {
        protected SimpleDateFormat initialValue()
            {
                return new SimpleDateFormat("yyyy-MM-dd");
            }
    };

要访问具体的格式化方法,可以调用:

String dateStamp = dateFormat.get().format(new Date());

在一个给定线程中首次调用get时,会调用initialValue方法。在此之后,get方法会返回属于当前线程的那个实例。

https://www.cnblogs.com/fsmly/p/11020641.html

3、锁测试与超时

线程在调用lock方法获得另一个线程持有的锁的时候,很可能发生阻塞。

trylock方法试图去申请一个锁,在成功获得锁后会返回一个true,否则,立即返回false,而线程可以立即离开去做其他事情。

if(mylock.trylock()){
    // now the thread owns the lock
    try{...}
    finally{ mylock.unlock();}
}
else
// do something else

可以调用tryLock时,使用超时参数:

if(mylock.tryLock(100,TimeUnit.MILLISECONDS))

lock方法不能被中断。如果一个线程在等待获得一个锁时被中断,中断线程可能在获得锁之前一直处于阻塞状态,如果出现死锁,lock将无法终止。

如果带有超时参数的tryLock方法,那么如果等待期间线程被中断,会抛出InterruptedException异常,这是一个很好的特性,允许程序打破死锁。

调用lockInterruptibly方法,就相当于一个超时设为无限的tryLock方法。 

在等待条件对象时候也可以提供一个超时:

myCondition.await(100,TimeUnit.MILLISECONDS)

如果一个线程被另一个线程通过调用signalAll或signal激活,或者超时时限已达到,或者线程被中断,那么await方法将返回。

4、读/写锁

java.until.concurrent.locks包定义了两个锁类,我们已经讨论的ReentrantLock类和ReentranReandWriteLock类。如果很多线程从一个数据结构读取数据而很少线程修改其中数据的话,后者是十分有用的。在这种情况下,允许读者线程共享访问是合适的,当然,写者线程依然必须是互斥访问。 

下面是读写锁的必要步骤:  

(1)构造一个ReentranReandWriteLock对象:

private ReetrantReadWriteLock rwl = new ReentrantReanWriteLock();

 (2)抽取读锁和写锁:

//得到一个可以被多个读操作共用的读锁,但会排斥所有写操作
private Lock readLock = rwl.readLock();
//得到一个写锁,排斥所有其他的读操作和写操作
private Lock writeLock = rwl.writeLock();

(3)对所有的获取方法加读锁:

public double getTotalBalance()
{
    readLock.lock();
    try{...}
    finally{readLock.unlock();}
}

(4)对所有的修改方法加写锁:

public void transfer(...)
{
    writeLock.lock();
    try{...}
    finally{writeLock.unLock();}
}

5、为什么弃用stop和suspend方法

初始的Java版本定义了stop和suspend方法。

stop:用来终止一个线程

suspend:用来阻塞一个线程直至另一个线程调用resume

它们都试图控制一个给定线程的行为。stop方法天生就不安全,suspend方法会经常导致死锁。

stop方法会终止所有未结束的方法,包括run方法并且释放锁。因此,可能会导致对象处于不一致的状态。

如果使用suspend挂起一个持有锁的线程,那么,该锁在恢复之前是不可用的。如果调用suspend方法的线程试图获得同一个锁,那么程序死锁:被挂起的线程等着被恢复,而将其挂起的线程等待获得锁。

参考:《Java核心技术卷I》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值