Java 锁的使用与锁的类型

部分内容转载自其他论坛,因时间久远未能找到出处,请原作者见谅。


1 概念

1.1 线程安全

指在并发的情况之下,该代码经过多线程使用,线程的调度顺序不影响任何结果。这个时候使用多线程,我们只需要关注系统的内存、cpu是不是够用即可。反过来,线程不安全就意味着线程的调度顺序会影响最终结果,如不加事务的转账代码:

void transferMoney(User from, User to, float amount){
	to.setMoney(to.getBalance() + amount);
    from.setMoney(from.getBalance() - amount);
}

假如执行完第一行代码时,线程的时间片刚好用完,切换到另一个线程,而另一个线程就会看到to对象的Balance增加,但是from对象的Balance并没有减少。

1.2 同步

Java中的同步指的是通过人为的控制和调度,保证共享资源的多线程访问成为线程安全,来保证结果的准确。如上面的代码简单加入@synchronized关键字。在保证结果准确的同时,提高性能,才是优秀的程序。线程安全的优先级高于性能。

同步还可以使用信号量的方式,详见《现代操作系统》。

1.3 锁对象

在Java中,每一个对象都拥有一个锁标记(monitor),也称为监视器,多个线程同时访问某个对象时,线程只有获取了该对象的锁才能访问。

“锁对象”一般指需要加锁的共享资源(对象实例)。在确定锁对象的时候,可以先明确哪些是共享资源,不同的线程只有在访问共享资源的时候才会发生资源读写冲突问题。

假如当前类A存在两个实例,一个A1,一个A2,如果线程1访问的是A1对象的同步方法,而线程2访问的是A2对象的同步方法,那么实际上这两个线程并没有发生资源读写冲突,因为两个A实例并不属于共享资源,所以即使 A 的方法上锁也没什么意义。

所以使用锁前,一定要明确是否存在对同一资源的读写冲突。

2 - Java加锁方式

基本上所有的并发模式在解决线程安全问题时,都采用“序列化访问临界资源”的方案,即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。

通常来说,是在访问临界资源的代码前面加上一个锁,当访问完临界资源后释放锁,让其他线程继续访问。

下面介绍三种常见的加锁方式。

2.1 synchronized 修饰的同步方法

对共享资源进行访问的方法定义中加上synchronized关键字修饰,使得此方法称为同步方法

class A{
	...
	public synchronized void func(){
		// 执行体
	}
	...
}

可以简单理解成对此方法进行了加锁,其锁对象为当前方法所在的对象自身。多线程环境下,当执行此方法时,首先都要获得此同步锁,只有当线程执行完此同步方法后,才会释放锁对象,其他的线程才有可能获取这个锁。

2.2 同步代码块

使用同步方法时,使得整个方法体都成为了同步执行状态,会使得可能出现同步范围过大的情况,于是,针对需要同步的代码可以使用同步代码块来解决。

格式如下,其中 obj 就是要上锁的对象,一般是多线程会共享的资源。如果同步代码在资源对象的内部,可以将 obj 写作 this。

/* 1. 资源是内部成员变量 */
public class B{
	private A obj1;
	private A obj2;
	...
	public void func1(){
		synchronized (obj1){
	   		// 执行体
		}
	}

	public void func2(){
		synchronized (obj3){
			// 执行体
		}
	}	
	...
}

/* 2. 资源是类对象 */
public class A{
	...
	public void func(){
		synchronized (this){
	   		// 执行体
	   	}
	}
	...
}	

上面同步代码块的两种情况有什么区别呢?
很明显,第一种情况下,两个不同的线程可以同时调用类 B 的 func1()func 2() ,因为锁对象是括号内的 obj1obj2,这两个锁可以被不同线程获得。而第二种写法和同步方法的区别不大,因为共享资源或者锁对象都是类 A 本身,同一时刻只有一个线程可以获得 A 的锁并调用 A 的方法。

2.3 Lock对象同步锁

class X {
    // Lock同步锁对象,此对象与共享资源具有一对一关系,此时共享资源将是X的一个实例对象
    private final Lock lock = new ReentrantLock();
    
    public void m(){
    	lock.lock(); 
    	try{  
    		...        
        	// 需要进行线程安全同步的代码
        	...
        }catch(Execption e){
        	...
        }finally{
        	lock.unlock();
        }
    }
}

Lock 和 synchronized 的区别?

  1. synchronized 是 JVM 层面的技术,是 Java 内置的关键字,所以 获取锁/释放锁 都是由虚拟机管理的;而 Lock 是一个 Java 类,我们需要手动管理锁。
  2. 出现异常时,JVM 会自动释放 synchronized 同步锁,但是 Lock 不会,所以使用 Lock 的时候我们需要搭配 try…catch…finally 代码块使用,并且将锁的释放放到 finally ,确保出现异常的时候可以释放锁。
  3. Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用 synchronized 时,等待的线程会一直等待下去,不能够响应中断;
  4. 通过Lock可以知道有没有成功获取锁,而 synchronized 却无法办到。
  5. Lock 可以提高多个线程进行读操作的效率。

在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。

更多详细内容参考: Java并发编程:Lock

3 - 实例

3.1 三个改变线程状态的系统方法

waitnotifynotifyAll 方法可以睡眠/唤醒线程,它们是 Object 类的 final native 方法,这些方法不能被子类重写,由于 Object 是所有 Java 类的超类,所以任何 Java 类都能直接调用这几个方法。

  • wait()
    导致当前线程等待并使其进入到等待阻塞状态。直到其他线程调用该同步锁对象的notify()notifyAll() 方法来唤醒此线程。
  • notify()
    唤醒在此同步锁对象上等待的单个线程,如果有多个线程都在此同步锁对象上等待,则会任意选择其中某个线程进行唤醒操作。(注意,虽然线程被唤醒,但只有当前线程放弃对同步锁对象的锁定,被唤醒的线程才可能执行被执行)
  • notifyAll()
    唤醒在此同步锁对象上等待的所有线程。同上,只有当前线程放弃对同步锁对象的锁定,才可能执行被唤醒的线程

3.2 线程的各种状态

下图为线程状态的转换图,学习 Java 锁相关内容时主要注意 Running、等待Blocked、锁定Blocked。

锁定Blocked就是被唤醒或者没有得到锁,此时线程需要获得锁之后才能进入运行态。
在这里插入图片描述

3.3 模拟存钱取钱

下面的代码创建了两个线程,一个是存钱线程,一个是取钱线程,前者run()不断存钱,后者run()不断取钱。整个程序只有两个线程运行,两个线程在Account对象实例上发生资源共享/竞争。

存钱线程/取钱线程:

class DrawMoneyThread extends Thread { // 取钱线程
    private Account account;
    private double amount;  // 取款数额

    public DrawMoneyThread(String threadName, Account account, double amount) {
        super(threadName);
        this.account = account;
        this.amount = amount;
    }

    public void run() {
        for (int i = 0; i < 100; i++) 
            account.draw(amount, i);  // 取100次钱
    }
}

class DepositeMoneyThread extends Thread {  // 存钱线程
    private Account account;
    private double amount; // 存款数额
    
    public DepositeMoneyThread(String threadName, Account account, double amount) {
        super(threadName);
        this.account = account;
        this.amount = amount;
    }

    public void run() {
        for (int i = 0; i < 100; i++)
            account.deposite(amount, i);  // 存100次钱
    }
}

资源对象:

public class Account {
    private String accountNo;
    private double balance;

    public Account() {}
    public Account(String accountNo, double balance) {
        this.accountNo = accountNo;
        this.balance = balance;
    }
    ...  //省略getter和setter,自己补充

    // 存钱同步方法
    public synchronized void deposite(double depositeAmount, int i) {
        setBalance(balance + depositeAmount);
        notifyAll();  // 唤醒取钱线程
        
        System.out.println(Thread.currentThread().getName() + " 存钱执行完毕,当前余额为:" + getBalance());
    }

    // 取钱同步方法
    public synchronized void draw(double drawAmount, int i) {
        if (getBalance() - drawAmount < 0) { // 账户中还没人存钱进去,此时当前线程需要等待阻塞
            try {
                System.out.println(Thread.currentThread().getName() + " 余额不足,执行wait操作,当前余额为:"+getBalance());
                wait();
                System.out.println(Thread.currentThread().getName() + " 执行了wait操作,线程被唤醒");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } else {
            setBalance(getBalance() - drawAmount);
            System.out.println(Thread.currentThread().getName() + " 取钱执行完毕,当前余额为:" + getBalance());
        }
    }
}

运行与结果:

public class ThreadTest {
    public static void main(String[] args) {
        Account account = new Account("123456", 0);
        // 两个线程都是对同一个Account对象进行操作
        Thread drawMoneyThread = new DrawMoneyThread("取钱线程", account, 700);
        Thread depositeMoneyThread = new DepositeMoneyThread("存钱线程", account, 700);
        drawMoneyThread.start();
        depositeMoneyThread.start();
    }
}

结果如下,若账号没钱就会阻塞取钱线程,并释放锁。存钱线程获得锁后就可以存钱,同时唤醒取钱线程,直到时间片消耗完。之后切换取钱线程就可以继续取钱了。

- 取钱线程 余额不足,执行wait操作,当前余额为:0.0
+ 存钱线程 存钱执行完毕,当前余额为:700.0
+ 存钱线程 存钱执行完毕,当前余额为:1400.0
- 取钱线程 执行了wait操作,线程被唤醒
- 取钱线程 取钱执行完毕,当前余额为:700.0
- 取钱线程 取钱执行完毕,当前余额为:0.0
- 取钱线程 余额不足,执行wait操作,当前余额为:0.0
+ 存钱线程 存钱执行完毕,当前余额为:700.0
+ 存钱线程 存钱执行完毕,当前余额为:1400.0
+ 存钱线程 存钱执行完毕,当前余额为:2100.0
+ 存钱线程 存钱执行完毕,当前余额为:2800.0
+ 存钱线程 存钱执行完毕,当前余额为:3500.0
+ 存钱线程 存钱执行完毕,当前余额为:4200.0
+ 存钱线程 存钱执行完毕,当前余额为:4900.0
+ 存钱线程 存钱执行完毕,当前余额为:5600.0
+ 存钱线程 存钱执行完毕,当前余额为:6300.0
+ 存钱线程 存钱执行完毕,当前余额为:7000.0
+ 存钱线程 存钱执行完毕,当前余额为:7700.0
+ 存钱线程 存钱执行完毕,当前余额为:8400.0
- 取钱线程 执行了wait操作,线程被唤醒
- 取钱线程 取钱执行完毕,当前余额为:7700.0
- 取钱线程 取钱执行完毕,当前余额为:7000.0
- 取钱线程 取钱执行完毕,当前余额为:6300.0
- 取钱线程 取钱执行完毕,当前余额为:5600.0
- 取钱线程 取钱执行完毕,当前余额为:4900.0
...

3.4 知识点

  1. wait() 方法执行后,当前线程立即进入到等待阻塞状态,并释放自身的锁。等到其他线程唤醒时,继续从wait()后面的代码开始执行。

  2. notify() / notifyAll()方法执行后,将唤醒此同步锁对象(在本例中就是account)上的线程对象。但是,此时还并没有释放同步锁对象,需要当前线程执行完毕才会释放同步锁对象;

  3. 持有锁的线程如果调用了 sleep() 方法,则会进入到阻塞状态,但是同步对象锁没有释放,其他线程还是没有机会运行;

  4. wait() / notify() / nitifyAll() 完成线程间的通信或协作都是基于相同的对象锁。因此,如果是不同的同步对象锁将失去意义。同步对象锁最好是与共享资源对象保持一一对应关系,也就是实例与锁一一对应。

4 - 锁的类型

  1. 乐观锁/悲观锁:它们其实并不是一种具体的锁,而是一种对如何看待并发的数据操作的描述。乐观锁对于并发的数据操作持乐观的态度,认为并发操作不会修改数据,不需要上锁。而悲观锁刚好相反,认为并发操作一定会修改数据,必须上锁。

  2. 可重入锁
    假如当前线程已经获得某共享资源的锁,它在接下来的执行中调用该资源的任何方法都不需要重新申请锁。举例:

    class C{
    	public synchronized void func1(){
    		// 执行体
    	}
    	public synchronized void func2(){
    		// 执行体
    		this.func1(); // func1也是同步方法,由于调用func2的时候已经获得锁,不需要再一次申请
    	}
    }
    

    synchronized 和 ReentrantLock 都具有可重入性。显然,不具有可重入性可能会导致死锁,因为线程已经持有锁却还在等待锁的释放。

  3. 自旋锁/自适应自旋锁
    第2节介绍的几种上锁方式都会同步阻塞不能获得锁的线程,但由于对线程的阻塞/唤醒、线程切换都会消耗一定的资源。自旋锁让线程在申请不到锁的情况下不会立即阻塞,它会尝试继续申请锁,类似于轮询。显然,在单核的情况下这种锁是没有用的,但在多核机器上,持有锁的线程和申请锁的线程可以并行,申请锁的线程有可能在 CPU 轮转时间使用期内等到锁的释放。如果自旋较少的次数就获得了锁,那么这种开销是可接受的,但是长时间的自旋会导致 CPU 浪费,可以设置最大自旋次数来减少浪费。
    自适应自旋锁是对自旋锁的一个优化,自旋的次数不再固定,而依据对自旋锁以往的获取历史。如果之前通过自旋获得过该锁,那么认为通过自旋的方式获得锁的几率较高,允许这一次自旋更多次。反之,如果通过自旋的方式很少成功获得锁,那么应该认为自旋是多余的,直接跳过自旋阶段,直接阻塞。

  4. 互斥锁/读写锁:互斥锁就涉及到临界区的概念了,无论是读还是写操作同一时刻只有一个线程可以进入临界区,比如 synchronized。读写锁实现的是读写、写读、写写互斥,但是允许同一时刻多个线程同时对数据进行读操作,比如 ReentrantReadWriteLock。

  5. 独占锁/共享锁:锁如果可以被多个线程持有,那就是共享锁,比如ReentrantReadWriteLock 的读锁。反之只能被一个线程持有的锁就是独占锁,synchronized、ReentrantReadWriteLock 的写锁等。

  6. 公平锁/非公平锁:公平锁表示锁的获得是按照先后顺序,就像买票排队一样,但是非公平锁不能保证先申请锁的线程一定能先得到锁,有可能优先级更高的线程获得锁,就像买票有VIP通道。

  7. 偏向锁/轻量级锁/重量级锁:这三种锁是指锁的状态,并且是针对 synchronized。在Java 5 通过引入锁升级的机制来实现高效 Synchronized。

    偏向锁 是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
    轻量级锁 是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁。其他线程不会阻塞,提高性能。
    重量级锁 是指当锁为轻量级锁的时候,另一个线程自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

  8. 锁消除/锁粗化:锁粗化和消除其实设计原理都差不多,都是为了减少没必要的加锁。

    锁消除是指虚拟机即时编译器在运行时,对一些不必要的同步进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
    如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

参考

  1. Java并发编程系列博客. Matrix海子. 博客园
  2. Java多线程 五 — JAVA锁有哪些种类

正文结束,欢迎留言讨论。如果觉得文章对你有帮助,不妨点击一下“喜欢”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值