同步问题:每一个线程对象轮番“抢占”共享资源带来的问题。(实际上占主导权的还是CPU,这里只是形象的描述)
引例: 卖票小程序
package 同步;
/**
* @BelongsProject: untitled
* @BelongsPackage: 同步
* @Author: mcc
* @CreateTime: 2020-10-09 08:27
* @Description:模拟卖票
*/
class Window implements Runnable {
private int ticket = 100;
@Override
public void run() {
while (true) {
if (ticket > 0) {
try {
Thread.sleep(100);//模拟网络延时
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "卖票,票号为:" + ticket);
ticket--;
} else {
break;
}
}
}
}
public class WindowTest {
public static void main(String[] args) {
Window window = new Window();
Thread thread1 = new Thread(window);
Thread thread2 = new Thread(window);
Thread thread3 = new Thread(window);
thread1.setName("窗口1");
thread2.setName("窗口2");
thread3.setName("窗口3");
thread1.start();
thread2.start();
thread3.start();
}
}
这时我们发现,票数不仅出现重复,而且出现负数,这种问题我们称之为不同步问题。
问:三个线程共享一个资源,为什么会出现负数票?
答:我们认为,当ticket为1时,只能打印1张票;但是,当一个线程进入if语句还没出来时,其余线程也可通过判断进入if语句,再有线程没有任何限制的去执行后面的代码,而线程获取cpu时间片是随机的,所以哪一个线程先出来是不确定的,所以会出现重复/负数。为了解决这种不同步操作造成的问题,提出了同步处理。
一、同步处理
所谓的同步指的是所有的线程不是一起进入到方法中执行,而是按照“顺序”一个一个来。
1.synchronized同步机制
- 同步代码块
- 同步方法
1.1同步代码块
synchronized (同步监视器){
//需要被同步的代码
}
说明:
- 操作共享数据的代码即为需要被同步的代码
- 共享数据:多个线程共同操作的变量,例如ticket
- 同步监视器:俗称:锁,任何一个类的对象都可以充当锁
- 要求:多个线程必须共用同一把锁
- 在继承Thread类创建多线程的方式中,慎用this充当同步监视器,考虑使用当前类充当同步监视器
具体分类 | 同步监视器 | 伪代码 |
---|---|---|
实例方法 | 类的实例对象 | synchronized (this){ } |
静态方法 | 类对象 | public static synchronized (类名称.class){ } 【全局锁】 |
任意实例对象Object | 实例对象Object | String lock = " ";synchronized(lock){ } |
class Window implements Runnable {
private int ticket = 100;
@Override
public void run() {
while (true) {
synchronized (this) {
if (ticket > 0) {
try {
Thread.sleep(100);//模拟网络延时
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "卖票,票号为:" + ticket);
ticket--;
} else {
break;
}
}
}
}
}
class Window2 extends Thread {
private static int ticket = 100;
@Override
public void run() {
while (true) {
synchronized (Window2.class) {
if (ticket > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(getName() + ": 卖票,票号为:" + ticket);
ticket--;
} else {
break;
}
}
}
}
}
1.2同步方法
如果操作共享数据的代码完整的声明在一个方法中,我们不妨将此方法声明为同步的
具体分类 | 同步监视器 | 伪代码 |
---|---|---|
实例方法 | 类的实例对象 | public synchronized void method(){ } |
静态方法 | 类对象 | public static synchronized void method(){ }【全局锁】 |
class Window3 implements Runnable {
private int ticket = 100;
@Override
public void run() {
while (true) {
show();
}
}
private synchronized void show() {
if (ticket > 0) {
try {
Thread.sleep(100);//模拟网络延时
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "卖票,票号为:" + ticket);
ticket--;
}
}
}
class Window4 extends Thread {
private static int ticket = 100;
@Override
public void run() {
while (true) {
show();
}
}
private static synchronized void show() {//同步监视器:Window4.class
if (ticket > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ": 卖票,票号为:" + ticket);
ticket--;
}
}
}
关于同步方法总结:
- 同步方法任然涉及到同步监视器,只是不需要我们显示声明
- 非静态的同步方法,同步监视器是:this
静态的同步方法,同步监视器是:当前类本身
同步代码块是在方法里拦截的,也就是说进入方法的线程依然可能会有多个。而同步方法是在方法上拦截的,保证了同一时刻只有一个线程进入该方法。
2.synchronized 锁多对象问题
class Sync{
public synchronized void test() {
System.out.println("test方法开始,当前线程为:"+Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("test方法结束,当前线程为:"+Thread.currentThread().getName());
}
}
class MyThread extends Thread{
@Override
public void run() {
// TODO Auto-generated method stub
super.run();
Sync sync = new Sync();
sync.test();
}
}
public class Test{
public static void main(String[] args) {
for(int i = 0;i<3;i++) {
Thread thread = new MyThread();
thread.start();
}
}
}
按照之前对synchronized的理解,当一个线程开始执行到结束后,另一个线程才进入该同步方法,而实际情况却是交叉执行与设想不同,这是为什么?
答:实际上synchronized(this)
与以及非static的synchronized方法,只能防止多个线程同时执行同一个对象的同步代码段。而本代码中是多个线程同时执行多个对象的同步代码段,所以synchronized关键字在此处看似无用。要明确synchronized锁住的是括号里的对象,而不是代码。对于非static的synchronized方法,锁的就是对象本身,也就是this。
那么怎么锁住这段代码?
Ⅰ.锁同一个对象
class Sync {
public void test() {
synchronized (this) {
System.out.println("test方法开始,当前线程为:" + Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("test方法结束,当前线程为:" + Thread.currentThread().getName());
}
}
}
class MyThread extends Thread {
private Sync sync;
public MyThread(Sync sync) {
this.sync = sync;
}
@Override
public void run() {//在这里,有三个线程,而sync对象依然只有一个
this.sync.test();
}
}
public class Test {
public static void main(String[] args) {
Sync sync = new Sync();//这里产生了一个对象
for (int i = 0; i < 3; i++) {
Thread thread = new MyThread(sync);//构造注入
thread.start();
}
}
}
Ⅱ.synchronized锁这个类对应的Class对象【全局锁】
class Sync{
public void test() {
synchronized (Sync.class) {
System.out.println("test方法开始,当前线程为:"+Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("test方法结束,当前线程为:"+Thread.currentThread().getName());
}
}
}
class MyThread extends Thread{
@Override
public void run() {
// TODO Auto-generated method stub
super.run();
Sync sync = new Sync();
sync.test();
}
}
public class Test{
public static void main(String[] args) {
for(int i = 0;i<3;i++) {
Thread thread = new MyThread();
thread.start();
}
}
}
如果想要锁的是代码段,锁住多个对象的同一方法,使用这种全局锁,锁的是类而不是this。
3.synchronized实现原理
Ⅰ.同步代码块
执行同步代码块后首先要执行monitorenter指令,退出时执行monitorexit指令。使用内建锁(synchronized)进行同步,关键是必须要对对象监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。通常一个monitorenter指令会搭配若干个monitorexit指令,这是因为Java虚拟机要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁。
Ⅱ.同步方法
当用synchronized标记方法时,编译后的字节码中方法的访问标记包括ACC_SYNCHRONIZED。该标记表示在进入该方法时,Java虚拟机要进行monitorenter操作。而在退出方法时,无论是否正常退出,Java虚拟机均需要进行monitorexit操作。
monitorenter和monitorexit的作用:每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
之所以采取这种计数器的方式,是 为了允许同一个线程重复获取同一把锁。
4.JDK1.5提供的锁代码块的Lock锁
- 从JDK1.5开始,Java提供了更强大的线程同步机制——通过显示定义同步锁对象来实现同步。同步锁使用Lock对象充当
- java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具。锁提供了共享资源的独占访问,每次只有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象
- ReentrantLock类实现了Lock,它拥有synchronized相同的并发性和内存语意,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显示加锁、释放锁
package LockTest;
import java.util.concurrent.locks.ReentrantLock;
/**
* @BelongsProject: untitled
* @BelongsPackage: LockTest
* @Author: mcc
* @CreateTime: 2020-10-16 07:37
* @Description:
*/
class Window implements Runnable {
private int ticket = 100;
//1.实例化ReentrantLock
private ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
while (true) {
try {
//2.调用锁定方法lock()
lock.lock();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (ticket > 0) {
System.out.println(Thread.currentThread().getName() + "卖票,票号为:" + ticket);
ticket--;
} else {
break;
}
} finally {
//3.调用解锁方法unlock()
lock.unlock();
}
}
}
}
public class LockTest {
public static void main(String[] args) {
Window w = new Window();
Thread t1 = new Thread(w);
Thread t2 = new Thread(w);
Thread t3 = new Thread(w);
t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");
t1.start();
t2.start();
t3.start();
}
}
synchronized与Lock的异同:
相同:二者都可以解决线程安全问题
不同:
- synchronized机制在执行完相应的同步代码块以后,自动的释放同步监视器
Lock需要手动的启动同步(lock()),同时结束同步也需要手动实现(unlock())
优先使用顺序:Lock–>同步代码块(已经进入了方法体,分配了相应资源)–>同步方法(在方法体之外)
5.synchronized的优化
5.1CAS操作:Compare And Swap( )——乐观锁
悲观锁(JDK1.6前的内建锁):假设每一次执行同步代码块均会产生冲突,所以当线程获取锁成功,会阻塞其他尝试获取该锁的线程。
乐观锁:假设所有线程访问共享资源时不会出现冲突,既然不会出现冲突就不会阻塞其他线程。线程不会出现阻塞状态。
什么是CAS操作?
CAS(无锁操作),使用CAS叫做比较交换来判断是否出现冲突,如果出现冲突就重试当前操作,知道不冲突为止。
Ⅰ.CAS操作过程
- V:内存中地址存放的实际值
- O:预期值(旧值)
- N:更新后的值
当执行CAS后:
如果V==0,即旧值与内存中实际值相等,表示上次修改该值后没有任何线程再次修改此值,因此可以将N替换到内存中。
如果V!=0,表示该内存的值已经被其他线程做了修改,所以无法将N替换,返回最新的值V。
当多个线程使用CAS操作一个变量时,只有一个线程会成功,并成功更新变量值,其余线程均会失败。失败的线程会重新尝试将该线程挂起(阻塞)。
CAS和synchorized(未优化前)的区别:
- synchorized(未优化前)在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。
- CAS并不是武断的将线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。
Ⅱ.CAS问题
①ABA问题
解决思路:沿用数据库的乐观锁机制,添加版本号
例如:1A–2B–3A,JDK1.5提供atomic包下的StampedReference类来解决CAS的ABA问题。
②自旋会浪费大量的处理器资源
因为此时线程仍处于运行状态,只不过跑的是无用指令,期望在运行无用指令的过程中,锁能被释放出来。
解决思路:自适应自旋–>根据以往自旋等待时能否获取到锁,来动态调整自旋的时间(循环尝试变量),如果在上次一自旋时获取到锁,则此次自旋时间稍微变长一点;如果上一次在自旋结束还没有获取到锁,此次自旋时间稍微短一点。
③公平性
处于阻塞状态的线程无法立刻竞争被释放的锁;而处于自旋状态的线程很有可能立刻获取到锁。内建锁无法实现公平性,lock体系可以实现公平性。
5.2偏向锁、轻量级锁、重量级锁
JDK1.6之后对内建锁做了优化,新增了偏向锁和轻量级锁。
锁状态 | 是否是偏向锁 | 锁标志位 |
---|---|---|
无锁状态 | 0 | 01 |
偏向锁 | 1 | 01 |
轻量级锁 | 00 | |
重量级锁 | 10 |
这四种状态随着竞争情况逐渐升级,锁可以升级不可以降级,为了提高获得锁与释放锁的效率。
偏向锁:最乐观的锁,从始至终只有一个线程请求一把锁。
引入偏向锁的目的:由于大多数情况下,锁不仅存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低
偏向锁的获取:当一个线程访问同步代码块并获取锁时,会在对象头和栈帧中的锁记录中记录存储偏向锁的线程ID。以后该线程再次进入同步块时不再需要CAS来加锁和减锁,只需要简单测试一下对象头markword中偏向锁的线程ID是否是当前线程ID,如果成功,表示线程已经获取到锁直接进入代码块运行。如果测试失败,检查当前偏向锁字段是否位0,如果为0,采用CAS操作将偏向锁字段设置为1,并且更新自己的线程ID到markword字段中。如果为1,表示此时偏向锁已经被别的线程获取,则此线程不断尝试使用CAS获取偏向锁,或者将偏向锁撤销,升级为轻量级锁(升级概率较大)。
偏向锁撤锁:偏向锁使用一种等待竞争出现才释放锁的机制,当有其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁。
注:偏向锁的撤销开销较大,需要等待线程进入全局安全点safepoint(当前线程在CPU上没有执行任何有用的字节码)。偏向锁从JDK1.6后默认开启,但是它在应用程序启动几秒后才激活。
- —XX:Biased LockingStartupDelay = 0,将延迟关闭,JVM一启动就激活偏向锁。
- —XX:UseBiased Locking = false,关闭偏向锁,程序里默认进入轻量级锁。
轻量级锁:多个线程在不同时间段请求同一把锁,也就是基本不存在锁竞争。针对此种情况,JVM采用轻量级锁来避免线程的阻塞以及唤醒。
加锁:线程在执行同步代码块前,JVM先在当前线程栈帧中创建用于建立存储锁记录的空间,并将对象头的Mark Word字段直接复制到此空间中。然后线程尝试使用CAS将对象头的Mark Word替换为指向锁记录的指针(指向当前线程),如果成功则表示获取到轻量级锁;如果失败,表示其他线程竞争轻量级锁,当前线程便使用自旋来不断尝试。
释放锁:解锁时会使用CAS将复制的Mark Word替换回对象头,如果成功,表示没有竞争发生,正常解释;如果失败,表示当前锁存在竞争,进一步膨胀为重量级锁。重量级锁会阻塞、唤醒请求加锁的线程,针对的是多个线程同一时刻竞争同一把锁的情况,JVM采用自适应自旋来避免线程在面对非常小的同步块时,仍会被阻塞以及唤醒。
偏向锁和轻量级锁的区别:轻量级锁采用CAS操作将锁对象的标记字段替换为指向线程的指针,存储着所对象原本的标记字段,针对的 是多个线程在不同时间段申请同一把锁的情况。偏向锁只会在第一次请求时采用CAS操作,在锁对象Mark Word字段中记录下当前线程的ID,此后运行中持有偏向锁的线程不再有加锁过程,针对的是锁仅会被同一线程持有。
总结:
Java虚拟机中synchronized关键字的实现,按照代价由高到低可以分为重量级锁、轻量锁和偏向锁三种。
- 重量级锁会阻塞、唤醒请求加锁的线程。它针对的是多个线程同时竞争同一把锁的情况。JVM采用了自适应自旋,来避免线程在面对非常小的synchronized代码块时,仍会被阻塞、唤醒的情况。
- 轻量级锁采用CAS操作,将锁对象的标记字段替换为一个指针,指向当前线程栈上的一块空间,存储着锁对象原本的标记字段。它针对的是多个线程在不同时间段申请同一把锁的情况。
- 偏向锁只会在第一次请求时采用CAS操作,在锁对象的标记字段中记录下当前线程的地址。在之后的运行过程中,持有该偏向锁的线程的加锁操作将直接返回。它针对的是锁仅会被同一线程持有的情况。
6.死锁
- 不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁
- 出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续
package 死锁;
/**
* @BelongsProject: untitled
* @BelongsPackage: 死锁
* @Author: mcc
* @CreateTime: 2020-10-14 08:16
* @Description: 演示线程的死锁问题
*/
public class ThreadTest {
public static void main(String[] args) {
StringBuffer s1 = new StringBuffer();
StringBuffer s2 = new StringBuffer();
new Thread(){
@Override
public void run() {
synchronized (s1){
s1.append("a");
s2.append("1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (s2){
s1.append("b");
s2.append("2");
System.out.println(s1);
System.out.println(s2);
}
}
}
}.start();
new Thread(new Runnable() {
@Override
public void run() {
synchronized (s2){
s1.append("c");
s2.append("3");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (s1){
s1.append("d");
s2.append("4");
System.out.println(s1);
System.out.println(s2);
}
}
}
}).start();
}
}
解决方法:
- 专门的算法、原则
- 尽量减少同步资源的定义
- 尽量避免嵌套同步