转载

「JAVA」Java 线程不安全分析,同步锁和Lock机制,哪个解决方案更好

线程不安全的问题分析:在小朋友抢气球的案例中模拟网络延迟来将问题暴露出来;示例代码如下:

public class ImplementsDemo {
    public static void main(String []args) {
        Balloon balloon = new Balloon();
        new Thread(balloon, "小红").start();
        new Thread(balloon, "小强").start();
        new Thread(balloon, "小明").start();
    }
}
// 气球
class Balloon extends Thread {
	
	private int num = 50;
	
	@Override
	public void run() {
		for (int i = 0; i < 50; i++) {
        	if (num > 0) {
						try {
							Thread.sleep(10);
						} catch (InterruptedException e) {
							e.printStackTrace();
            }
          		System.out.println(Thread.currentThread().getName() + "抢到了" + (num--) + "号气球")
        	}
		}
	}
}
复制代码

在线程中的 run 方法上不能使用 throws 来声明抛出异常,所以在 run 方法中调用有可能出现异常的代码时,只能使用 try-catch 将其捕获来处理。

原因是:子类覆盖父类方法时不能抛出新的异常,父类的 run 方法都没有抛出异常,子类就更加不能抛出异常了。详情可查看我的另一篇文章  「JAVA」运行时异常、编译时异常、自定义异常,通过案例实践转译和异常链

在上述案例中,通过引入 Thread.sleep(); 来模拟网络延迟,该方法的作用是 让当前线程进入睡眠状态 10 毫秒 ,此时其他线程就可以去抢占资源了, 方法的参数是睡眠时间,以毫秒为单位

通过观察运行结果,发现了问题:

「JAVA」Java 线程不安全分析,同步锁和Lock机制,哪个解决方案更好

在运行结果中,小红、小强两个小朋友都抢到了 14 号气球,也就是 14 号气球被抢到了 2 次。我们来梳理线程的运行过程来看看发生了什么:

  1. 小强和小红两个线程都拿到了 14 号气球,由于 线程调度 ,小强获得了 CPU 时间片,打印出了抢到的气球,而小红则进入睡眠;小强在打印后对 num 做了减一操作,此时 num13
  2. 小明线程开始运行,抢到了 13 号气球,并对 num 做了减一操作,此时 num12
  3. 小红线程醒来,打印出抢到的 14 号气球;此时的 num12 ,减一后结果为 11
  4. 由于多个线程是并发操作,所以对 num 做判断时可能上一个线程还未对 num 减一,故都能通过( num > 0 )的判断;

然后再来运行上述代码,得出如下的结果:

「JAVA」Java 线程不安全分析,同步锁和Lock机制,哪个解决方案更好

运行结果中出现了本不该出现的 0-1 ,因为按照正常逻辑,气球数量到 1 之后就不应该被打印和减一了。出现这样的结果是因为出现了以下的执行步骤:

  1. 小红、小强、小明都同时抢到了 1 号气球,由于线程调度,小强获取了 cpu 时间片,得以执行,而小明和小红则进入睡眠;小强打印出结果后,对 num 减一,此时 num0
  2. 小明醒来,获得的 num0 ,然后小明将 num 打印出来,再对 num 减一,此时 num-1
  3. 小红醒来,获得的 num-1 ,随后小红将 num 打印出来,再对 num 减一,此时怒木为 -2
  4. 由于多个线程是并发操作,所以对 num 做判断时可能上一个线程还未对 num 减一,故都能通过( num > 0 )的判断;

解决方案:

在案例中的抢气球其实是两步操作: 先抢到气球,再对气球总数减一 ;既然是两步操作,在并发中就完全有可能会被分开执行,且执行顺序无法得到控制;

想要解决上述的线程不安全的问题,就必须要将这两步操作作为一个 原子操作 ,保证其 同步运行 ;也就是当一个 线程A 进入操作的时候,其他线程只能在操作外等待,只有当 线程A 执行完毕,其他线程才能有机会进入操作。

原子操作:不能被分割的操作,必须保证其从一而终完全执行,要么都执行,要么都不执行。

为解决多线程并发访问同一个资源的安全性问题, Java 提供如下了几种不同的同步机制:

Lock

同步代码块

同步代码块:为了保证线程能够正常执行原子操作, Java 引入了线程同步机制,其语法如下:

synchronized (同步锁) {		
        // 需要同步操作的代码  	
        ... ...
}
复制代码

上述中 同步锁, 又称 同步监听对象、同步监听器、互斥锁 ,同步锁是一个抽象概念,可以理解为在对象上标记了一把锁;

Java 中可以使用任何对象作为同步监听对象, 但在项目开发中,我们会 把当前并发访问的共享资源对象作为同步监听对象 , 在任何时候,最多只能运行一个线程拥有同步锁

卫生间的使用就是一个很好的例子,一个卫生间在一段时间内只能被一个人使用,当一个人进入卫生间后,卫生间会被上锁,其他只能等待;只有当使用卫生间的人使用完毕,开锁后才能被下一个人使用。

然后就可以使用同步代码块来改写抢气球案例,示例代码如下:

public class ImplementsDemo {
    public static void main(String []args) {
        Balloon balloon = new Balloon();
        new Thread(balloon, "小红").start();
        new Thread(balloon, "小强").start();
        new Thread(balloon, "小明").start();
    }
}

// 气球
class Balloon implements Runnable {
	
	private int num = 500;
	
	@Override
	public void run() {
		for (int i = 0; i < 500; i++) {
			synchronized (this) {
				if (num > 0) {
          			System.out.println(Thread.currentThread().getName() + "抢到了" 
                     + num + "号气球");
         			 num--;
        		}
			}
        
		}
	}
}
复制代码

通过查看运行结果,线程同步的问题已经得到解决。

同步方法

同步方法:使用 synchronized 修饰的方法称为同步方法,能够保证当一个线程进入该方法的时候,其他线程在方法外等待。比如:

public synchronized void doSomething() {		
        // 方法逻辑	
}
复制代码

PS:方法修饰符不分先后顺序。

使用同步方法来改写抢气球案例,代码如下:

public class ImplementsDemo {
    public static void main(String []args) {
        Balloon balloon = new Balloon();
        new Thread(balloon, "小红").start();
        new Thread(balloon, "小强").start();
        new Thread(balloon, "小明").start();
    }
}

// 气球
class Balloon implements Runnable {
	
	private int num = 500;
	
	@Override
	public void run() {
		for (int i = 0; i < 500; i++) {
        	grabBalloon();
		}
	}
	// 抢气球
	private synchronized void grabBalloon() {
		if (num > 0) {
			System.out.println(Thread.currentThread().getName() + "抢到了" 
							   + num + "号气球");
			num--;
		}
	}
}
复制代码

注意: 不能使用 synchronized 修改线程类中的 run 方法 ,因为使用之后,就会出现一个线程执行完了所有功能,多个线程出现串行;原本是多行道,使用 synchronized 修改线程类中的 run 方法,多行道变成了单行道。

synchronized 的好与坏

好: synchronized 保证了并发访问时的同步操作,避免了线程的安全性问题。

坏: 使用 synchronized 的方法、代码块的性能会比不用要低一些。

StringBuilder和StringBuffer

StringBuilderStringBuffer 区别就在于 StringBuffer 中的方法都使用了 synchronized 修饰, StringBuilder 中的方法没有使用 synchronized 修饰;这也是 StringBuilder 性能比 StringBuffer 高的主要原因。

Vector和ArrayList

两者都有同样的方法,有同样的实现算法,唯一不同就是 Vector 中的方法使用了 synchronized 修饰,所以 Vector 的性能要比 ArrayList 低。

Hashtable和HashMap

两者都有同样的方法,有同样的实现算法,唯一不同就是 Hashtable 中的方法使用了 synchronized 修饰,所以 Hashtable 的性能要比 HashMap 低。

volatile关键字

volatile 关键字的作用在于:被 volatile 关键字修饰的变量的值,将不会被本地线程缓存,所有对该变量的读写都是直接操作共享内存,从而可以确保多个线程能正确处理该变量。

需要注意的是, volatile 关键字可能会屏蔽虚拟机中的一些必要的优化操作,所以运行效率不是很高,因此,没有特别的需要,不要使用;即便使用,也要避免大量使用。

单例模式

单例模式--饿汉模式

代码如下:

public class SlackerDemo {

	private SlackerDemo() {}
	
	private static SlackerDemo instance = null;
	
	public static SlackerDemo getInstance() {
		if (instance == null) {
			instance = new SlackerDemo();
		}
		return instance;
	}
	
}
复制代码

单例模式--懒汉模式

代码如下:

public class SlackerDemo {

	private SlackerDemo() {}
	
	private static SlackerDemo instance = null;
	
	public static SlackerDemo getInstance() {
		if (instance == null) {
			instance = new SlackerDemo();
		}
		return instance;
	}
	
}
复制代码

懒汉模式存在线程不安全问题,在对 instance 对象做判断时由于并发导致出现和抢气球案例一样的问题。为了解决这个问题,使用 双重检查加锁机制 来解决。

双重检查加锁机制

使用“双重检查加锁”机制实现的程序,既能实现线程安全,有能够使性能不受较大的影响。那么何谓“双重检查加锁”机制?其指的是:

  • 并不是每次进入 getInstance 方法都需要同步,而是先不同步,进入方法后, 先检查实例是否存在 ,如果不存在才执行同步代码块,这是**第一重检查;

  • 进入同步块后,再次检查实例是否存在,如果不存在,就在同步块中创建一个实例,这是 第二重检查

这样,就只需要同步一次,减少了多次在同步情况判断所浪费的时间。

“双重检查加锁”机制的实现需要 volatile 关键字的配合使用,且 Java 版本需要在 Java 5 及以上,虽然该机制可实现线程安全的单例模式,也要根据实际情况酌情使用,不宜大量推广使用。

使用“双重检查加锁”机制改写后的懒汉模式,代码如下:

public class SlackerDemo {

	private SlackerDemo() {}
	
	private static SlackerDemo instance = null;
	
	public static SlackerDemo getInstance() {
		if (instance == null) {
			synchronized (SlackerDemo.class) {
				if (instance == null) {
					instance = new SlackerDemo();
				}
			}
		}
		return instance;
	}
}
复制代码

Lock 锁机制

「JAVA」Java 线程不安全分析,同步锁和Lock机制,哪个解决方案更好

java.util.concurrent.locks 包提供了 Lock 接口, Lock 锁机制提供了比 synchronized 代码块和 synchronized 方法更广泛的锁定操作,而且功能比 synchronized 代码块和 synchronized 方法更加强大。

官方的提供了参考价值很大的 demo ,能够很好的提现 Lock 机制的功能:

「JAVA」Java 线程不安全分析,同步锁和Lock机制,哪个解决方案更好

使用 Lock 机制改写的抢气球案例代码如下所示:

import java.util.concurrent.locks.*;

public class LockDemo {
    public static void main(String []args) {
        Balloon balloon = new Balloon();
        new Thread(balloon, "小红").start();
        new Thread(balloon, "小强").start();
        new Thread(balloon, "小明").start();
    }
}

// 气球
class Balloon implements Runnable {
	
	private int num = 500;
	private final Lock lock = new ReentrantLock(); // 创建锁对象
	
	@Override
	public void run() {
		for (int i = 0; i < 500; i++) {
        	grabBalloon();
		}
	}
	// 抢气球
	private void grabBalloon() {
		lock.lock(); // 获取锁对象
		if (num > 0) {
			try {
				System.out.println(Thread.currentThread().getName() + "抢到了" 
							   + num + "号气球");
				num--;
			} catch (Exception e) {
				
			} finally {
				lock.unlock(); // 释放锁
			}
		}
	}
}
复制代码

案例运行正常。

完结。老夫虽不正经,但老夫一身的才华!关注我,获取更多编程科技知识。

原文  https://juejin.im/post/5ef2137b518825657f6f9dcf
正文到此结束
Loading...