多线程的“共享性”,意味着在程序中的变量可以由多个线程同时访问。而“可变性”则意味着变量的值在其生命周期内可以发生变化。本篇博客记录在 Java 的多线程学习中如何防止多个线程在数据上发生不受控的并发访问~
一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象的可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。 通过以下示例来解释:
public class ThreadDemo_线程不安全示例 {
private static int number = 0;
static class Counter {
// 循环次数
private static int MAX_COUNT = 1000000;
// ++ 方法
public static void incr() {
for (int i = 0; i < MAX_COUNT; i++) {
number++;
}
}
// -- 方法
public static void decr() {
for (int i = 0; i < MAX_COUNT; i++) {
number--;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
Counter.incr();
});
t1.start();
Thread t2 = new Thread(() -> {
Counter.decr();
});
t2.start();
// 等待线程执行完成
t1.join();
t2.join();
System.out.println("最终的结果:" + number);
}
}
这个代码很容易看出来,过程是给定了一个循环次数,在 线程1 中对其进行 ++ 操作,在 线程2 中进行相同次数的 - - 操作。想要得到的最终结果当然是 0 了,但是以上代码的运行结果:
是的,每次的结果都是随机的也是不正确的,这就是因为多个线程无法实现协同,导致了数据破坏出现了不该出现的结果~
那么就引出:
当多个线程访问某个状态的变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些变量的访问。 Java 中的主要同步机制是关键字 synchronized,它提供了一种独占的加锁方式,但“同步”这个术语还包括 volatile 类型的变量。
以上笼统的介绍了线程安全性的问题,那么在解决问题之前有必要先来明确一下 Java 中导致线程不安全的因素~~~
count++
操作,它并不是一个原子性操作,它的操作是分为三步的:1. 查询 count 当前的值【load】 2.进行 count+1 操作【++】 3. 刷新 count 的最新值【save】。
线程之间的共享变量存在主内存 (Main Memory).
每一个线程都有自己的 “工作内存” (Working Memory) .
当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据. 当线程要修改一个共享变量的时候,
也会先修改工作内存中的副本, 再同步回主内存。
如下场景:
① 初始情况下, 两个线程的工作内存内容一致。
② 一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的 工作内存的 a 的值也不一定能及时同步。
这就是内存的不可见性~
volatile 可以解决内存可⻅性和指令重排序的问题,代码在写⼊ volatile 修饰的变量的时候:
简单来说,他就是禁止了编译器指令重排序这一优化操作,也通过变量副本的方式保证了内存可见性~
public class TheradDemoVolatile {
private static volatile boolean flag = false;
public static void main(String[] args){
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("线程1 开始执行");
while (!flag) {
}
System.out.println("终⽌执⾏");
}
});
t1.start();
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("在线程2 中设置 flag=true");
flag = true;
}
});
t2.start();
}
}
如上代码,如果对 flag 变量不加 volatile 修饰,则永远执行不到System.out.println("终⽌执⾏")
;这行代码。因为在线程2 中的修改对线程1 不可见~ 当用 volatile 修饰之后,能够解决这个代码中的线程安全问题。
不足: volatile 虽然可以解决内存可⻅性和指令重排序的问题,但是解决不了原⼦性问题,因此对于 ++ 和 - - 操作的线程不安全问题依然解决不了
Java提供了一种内置锁机制来支持原子性~以关键字 synchronized来修饰的方法就是一钟横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。其中静态的 synchronized 方法以Class 对象作为锁。
//定义一个任意的对象
Object myLock = new Object();
synchronized(myLock) {
//访问或修改由锁保护的共享状态
}
/**
* synchronized 修饰静态方法
*/
public class ThreadSynchronized {
private static int number = 0;
static class Counter {
// 循环次数
private static int MAX_COUNT = 1000000;
// ++ 方法
public synchronized static void incr() {
for (int i = 0; i < MAX_COUNT; i++) {
number++;
}
}
// -- 方法
public synchronized static void decr() {
for (int i = 0; i < MAX_COUNT; i++) {
number--;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
Counter.incr();
});
t1.start();
Thread t2 = new Thread(() -> {
Counter.decr();
});
t2.start();
// 等待线程执行完成
t1.join();
t2.join();
System.out.println("最终的结果:" + number);
}
}
/**
* synchronized 修饰普通方法
*/
public class ThreadSynchronized2 {
private static int number = 0;
static class Counter {
// 循环次数
private static int MAX_COUNT = 1000000;
// ++ 方法
public synchronized void incr() {
for (int i = 0; i < MAX_COUNT; i++) {
number++;
}
}
// -- 方法
public synchronized void decr() {
for (int i = 0; i < MAX_COUNT; i++) {
number--;
}
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
counter.incr();
});
t1.start();
Thread t2 = new Thread(() -> {
counter.decr();
});
t2.start();
// 等待线程执行完成
t1.join();
t2.join();
System.out.println("最终的结果:" + number);
}
}
/**
* synchronized 修饰代码
*/
public class ThreadSynchronized3 {
private static int number = 0;
static class Counter {
// 循环次数
private static int MAX_COUNT = 1000000;
// 自定义锁对象(属性名可以自定义)
private Object mylock = new Object();
// ++ 方法
public void incr() {
for (int i = 0; i < MAX_COUNT; i++) {
synchronized (mylock) {
number++;
}
}
}
public static void test() {
synchronized (Counter.class) {
}
}
// -- 方法
public void decr() {
for (int i = 0; i < MAX_COUNT; i++) {
synchronized (mylock) {
number--;
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
counter.incr();
});
t1.start();
Thread t2 = new Thread(() -> {
counter.decr();
});
t2.start();
// 等待线程执行完成
t1.join();
t2.join();
System.out.println("最终的结果:" + number);
}
}
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也 执行到同一个对象 synchronized 就会阻塞等待.
synchronized 的⼯作过程:
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
/**
* synchronized 可重入性测试
*/
public class ThreadSynchronized {
public static void main(String[] args) {
synchronized (ThreadSynchronized.class) {
System.out.println("当前主线程已经得到了锁");
synchronized (ThreadSynchronized.class) { //可以进入第二层
System.out.println("当前主线程再次得到了锁");
}
}
}
}
我们观察一下一个被 synchronized 修饰加锁的代码在 JVM 层面的字节码是如何实现的:
结论:
synchronized 同步锁是通过 JVM 内置的 Monitor 监视器实现的,而监视器又是依赖操作系统的互斥锁 Mutex 实现的。JVM 监视器的执行流程是:线程先通过自旋 CAS 的方式尝试获取锁,如果获取失败就 进⼊ EntrySet 集合,如果获取成功就拥有该锁。当调用 wait() ⽅法时,线程释放锁并进⼊ WaitSet 集合,等其他线程调用 notify 或 notifyAll 方法时再尝试获取锁。锁使用完之后就会通知 EntrySet 集合中的线程,让它们尝试获取锁。
链接: link.
在 Java 中,synchronized 是非公平锁,也是可以重入锁。
- 所谓的非公平锁是指,线程获取锁的顺序不是按照访问的顺序先来先到的,而是由线程自己竞争,随机获取到锁。
- 可重入锁指的是,一个线程获取到锁之后,可以重复得到该锁。也就是多层的获取进入这个锁
ObjectMonitor::ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; //线程的重⼊次数
_object = NULL;
_owner = NULL; //标识拥有该monitor的线程
_WaitSet = NULL; //等待线程组成的双向循环链表,_WaitSet是第⼀个节点
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //多线程竞争锁进⼊时的单向链表
FreeNext = NULL ;
_EntryList = NULL ; //_owner从该双向循环链表中唤醒线程结点,_EntryList
是第⼀个节点
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
_owner
字段设置为当前线程 id,说明当前线程已经持有锁,并将_recursions
重入次数的属性+1。如果获取锁失败则先通过自旋 CAS 再尝试获取锁,如果还是获取失败就将当前线程放入 EntryList 监控队列(阻塞)。_owner
变量恢复为 NULL 状态,同时将该线程放入 WaitSet 待授权队列中等待被唤醒。Lock 是一个接口,一般使用 ReentrantLock 类作为锁。在加锁和解锁处需要通过 lock() 和 unlock() 显示指出。所以一般会在 finally 块中写 unlock() 以防死锁。
lock.lock();
try {
}finally {
lock.unlock();
}
unlock()
放在 finally 块中以防死锁lock()
放在 try 外或者 tyr 的首行,因为1. try 代码中的异常导致加锁失败,还会执行 finally 释放锁操作。2. 释放锁的错误信息会覆盖业务代码报错信息,从而增加调试程序和修复程序的复杂度。构造方法 :
ReentrantLock() : 创建一个 ReentrantLock的实例。
ReentrantLock(boolean fair) : 根据给定的公平政策创建一个 ReentrantLock的实例。
区别:1、lock是一个接口,而synchronized是java的一个关键字。2、synchronized在发生异常时会自动释放占有的锁,因此不会出现死锁;而lock发生异常时,不会主动释放占有的锁,必须手动来释放锁,可能引起死锁的发生。
类别 | synchronized | Lock |
---|---|---|
存在层次 | Java的关键字,在jvm层面上 | 是一个类 |
锁的释放 | 1、以获取锁的线程执行完同步代码,释放锁; 2、线程执行发生异常,jvm会让线程释放锁 | 在finally中必须释放锁,不然容易造成线程死锁 |
锁的获取 | 假如A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 | 分情况而定,Lock有多个锁获取的方式,可以尝试继续获取锁,线程不用一直等待 |
锁的状态 | 无法判断 | 可以判断 |
锁类型 | 可重入、不可中断、非公平 | 可重入、可判断、可公平 |
性能 | 少量同步 | 大量同步 |
区别如下:
unlock()
来释放锁,可能引起死锁的发生。(所以最好将同步代码块用try catch包起来,finally中写入unlock,避免死锁的发生。)trylock()
来知道有没有获取锁,而synchronized 不能;//Condition定义了等待/通知两种类型的方法
Lock lock=new ReentrantLock();
Condition condition=lock.newCondition();...condition.await();...condition.signal();
condition.signalAll();
在 Java1.5 中,synchronized 是性能低效的。因为这是一个重量级操作,需要调用操作接口,导致有可能加锁消耗的系统时间比加锁外的操作还多。相比之下使用 Java 提供的 Lock 对象,性能更高一些。
但是到了 Java1.6,发生了变化。synchronized 在语法上很清晰,可以进行很多优化,有适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在Java1.6上synchronize的性能并不比Lock差。
2种机制的具体区别:
synchronized原始采用的是CPU悲观锁机制,即线程获得的是独占锁。 独占锁意味着其他线程只能依靠阻塞来等待线程释放锁。而在CPU转换线程阻塞时会引起线程上下文切换,当有很多线程竞争锁的时候,会引起CPU频繁的上下文切换导致效率很低。
而Lock用的是乐观锁方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。 乐观锁实现的机制就是CAS操作(Compare and Swap)。我们可以进一步研究ReentrantLock的源代码,会发现其中比较重要的获得锁的一个方法是compareAndSetState。这里其实就是调用的CPU提供的特殊指令。
现代的CPU提供了指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。这个算法称作非阻塞算法,意思是一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法。