部分内容转载自其他论坛,因时间久远未能找到出处,请原作者见谅。
指在并发的情况之下,该代码经过多线程使用,线程的调度顺序不影响任何结果。这个时候使用多线程,我们只需要关注系统的内存、cpu是不是够用即可。反过来,线程不安全就意味着线程的调度顺序会影响最终结果,如不加事务的转账代码:
void transferMoney(User from, User to, float amount){
to.setMoney(to.getBalance() + amount);
from.setMoney(from.getBalance() - amount);
}
假如执行完第一行代码时,线程的时间片刚好用完,切换到另一个线程,而另一个线程就会看到to对象的Balance增加,但是from对象的Balance并没有减少。
Java中的同步指的是通过人为的控制和调度,保证共享资源的多线程访问成为线程安全,来保证结果的准确。如上面的代码简单加入@synchronized关键字。在保证结果准确的同时,提高性能,才是优秀的程序。线程安全的优先级高于性能。
同步还可以使用信号量的方式,详见《现代操作系统》。
在Java中,每一个对象都拥有一个锁标记(monitor),也称为监视器,多个线程同时访问某个对象时,线程只有获取了该对象的锁才能访问。
“锁对象”一般指需要加锁的共享资源(对象实例)。在确定锁对象的时候,可以先明确哪些是共享资源,不同的线程只有在访问共享资源的时候才会发生资源读写冲突问题。
假如当前类A存在两个实例,一个A1,一个A2,如果线程1访问的是A1对象的同步方法,而线程2访问的是A2对象的同步方法,那么实际上这两个线程并没有发生资源读写冲突,因为两个A实例并不属于共享资源,所以即使 A 的方法上锁也没什么意义。
所以使用锁前,一定要明确是否存在对同一资源的读写冲突。
基本上所有的并发模式在解决线程安全问题时,都采用“序列化访问临界资源”的方案,即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。
通常来说,是在访问临界资源的代码前面加上一个锁,当访问完临界资源后释放锁,让其他线程继续访问。
下面介绍三种常见的加锁方式。
对共享资源进行访问的方法定义中加上synchronized关键字修饰,使得此方法称为同步方法。
class A{
...
public synchronized void func(){
// 执行体
}
...
}
可以简单理解成对此方法进行了加锁,其锁对象为当前方法所在的对象自身。多线程环境下,当执行此方法时,首先都要获得此同步锁,只有当线程执行完此同步方法后,才会释放锁对象,其他的线程才有可能获取这个锁。
使用同步方法时,使得整个方法体都成为了同步执行状态,会使得可能出现同步范围过大的情况,于是,针对需要同步的代码可以使用同步代码块来解决。
格式如下,其中 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()
,因为锁对象是括号内的 obj1
和 obj2
,这两个锁可以被不同线程获得。而第二种写法和同步方法的区别不大,因为共享资源或者锁对象都是类 A 本身,同一时刻只有一个线程可以获得 A 的锁并调用 A 的方法。
class X {
// Lock同步锁对象,此对象与共享资源具有一对一关系,此时共享资源将是X的一个实例对象
private final Lock lock = new ReentrantLock();
public void m(){
lock.lock();
try{
...
// 需要进行线程安全同步的代码
...
}catch(Execption e){
...
}finally{
lock.unlock();
}
}
}
Lock 和 synchronized 的区别?
在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。
更多详细内容参考: Java并发编程:Lock
wait
、notify
和 notifyAll
方法可以睡眠/唤醒线程,它们是 Object 类的 final native 方法,这些方法不能被子类重写,由于 Object 是所有 Java 类的超类,所以任何 Java 类都能直接调用这几个方法。
notify()
或 notifyAll()
方法来唤醒此线程。下图为线程状态的转换图,学习 Java 锁相关内容时主要注意 Running、等待Blocked、锁定Blocked。
锁定Blocked就是被唤醒或者没有得到锁,此时线程需要获得锁之后才能进入运行态。
下面的代码创建了两个线程,一个是存钱线程,一个是取钱线程,前者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
...
wait()
方法执行后,当前线程立即进入到等待阻塞状态,并释放自身的锁。等到其他线程唤醒时,继续从wait()后面的代码开始执行。
notify()
/ notifyAll()
方法执行后,将唤醒此同步锁对象(在本例中就是account)上的线程对象。但是,此时还并没有释放同步锁对象,需要当前线程执行完毕才会释放同步锁对象;
持有锁的线程如果调用了 sleep()
方法,则会进入到阻塞状态,但是同步对象锁没有释放,其他线程还是没有机会运行;
wait()
/ notify()
/ nitifyAll()
完成线程间的通信或协作都是基于相同的对象锁。因此,如果是不同的同步对象锁将失去意义。同步对象锁最好是与共享资源对象保持一一对应关系,也就是实例与锁一一对应。
乐观锁/悲观锁:它们其实并不是一种具体的锁,而是一种对如何看待并发的数据操作的描述。乐观锁对于并发的数据操作持乐观的态度,认为并发操作不会修改数据,不需要上锁。而悲观锁刚好相反,认为并发操作一定会修改数据,必须上锁。
可重入锁
假如当前线程已经获得某共享资源的锁,它在接下来的执行中调用该资源的任何方法都不需要重新申请锁。举例:
class C{
public synchronized void func1(){
// 执行体
}
public synchronized void func2(){
// 执行体
this.func1(); // func1也是同步方法,由于调用func2的时候已经获得锁,不需要再一次申请
}
}
synchronized 和 ReentrantLock 都具有可重入性。显然,不具有可重入性可能会导致死锁,因为线程已经持有锁却还在等待锁的释放。
自旋锁/自适应自旋锁
第2节介绍的几种上锁方式都会同步阻塞不能获得锁的线程,但由于对线程的阻塞/唤醒、线程切换都会消耗一定的资源。自旋锁让线程在申请不到锁的情况下不会立即阻塞,它会尝试继续申请锁,类似于轮询。显然,在单核的情况下这种锁是没有用的,但在多核机器上,持有锁的线程和申请锁的线程可以并行,申请锁的线程有可能在 CPU 轮转时间使用期内等到锁的释放。如果自旋较少的次数就获得了锁,那么这种开销是可接受的,但是长时间的自旋会导致 CPU 浪费,可以设置最大自旋次数来减少浪费。
自适应自旋锁是对自旋锁的一个优化,自旋的次数不再固定,而依据对自旋锁以往的获取历史。如果之前通过自旋获得过该锁,那么认为通过自旋的方式获得锁的几率较高,允许这一次自旋更多次。反之,如果通过自旋的方式很少成功获得锁,那么应该认为自旋是多余的,直接跳过自旋阶段,直接阻塞。
互斥锁/读写锁:互斥锁就涉及到临界区的概念了,无论是读还是写操作同一时刻只有一个线程可以进入临界区,比如 synchronized。读写锁实现的是读写、写读、写写互斥,但是允许同一时刻多个线程同时对数据进行读操作,比如 ReentrantReadWriteLock。
独占锁/共享锁:锁如果可以被多个线程持有,那就是共享锁,比如ReentrantReadWriteLock 的读锁。反之只能被一个线程持有的锁就是独占锁,synchronized、ReentrantReadWriteLock 的写锁等。
公平锁/非公平锁:公平锁表示锁的获得是按照先后顺序,就像买票排队一样,但是非公平锁不能保证先申请锁的线程一定能先得到锁,有可能优先级更高的线程获得锁,就像买票有VIP通道。
偏向锁/轻量级锁/重量级锁:这三种锁是指锁的状态,并且是针对 synchronized。在Java 5 通过引入锁升级的机制来实现高效 Synchronized。
偏向锁 是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
轻量级锁 是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁。其他线程不会阻塞,提高性能。
重量级锁 是指当锁为轻量级锁的时候,另一个线程自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
锁消除/锁粗化:锁粗化和消除其实设计原理都差不多,都是为了减少没必要的加锁。
锁消除是指虚拟机即时编译器在运行时,对一些不必要的同步进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
正文结束,欢迎留言讨论。如果觉得文章对你有帮助,不妨点击一下“喜欢”。