线程同步(Lock、Condition、synchronized、AtomicInteger、volatile、ThreadLocal)

前言

在使用多线程编程之前,一定要谨记同步格言(摘自java核心技术卷):如果向一个变量写入值,而这个变量接下来可能会被另一个线程读取,或者,从一个变量读值,而这个变量可能是之前被另一个线程写入的,此时必须使用同步。并发有三大特性:

1、可见性:指当一个线程修改了共享变量之后,其他线程能够立即"看见"这个修改(即立即读取到预期的正确值);

2、原子性:指一个操作是不可中断的,要么全部执行成功要么全部执行失败;

3、有序性:指程序指令按照预期的顺序执行而非乱序执行,乱序又分为编译器乱序和CPU乱序(处理器为了提高程序运行效率,可能会对输入的代码进行优化,包括编译器重排序和处理器重排序,这种现象称为"指令重排")。

其中,加锁机制(这里的加锁机制指的java提供synchronized和Lock这些,并不是自己去实现的加解锁逻辑,因为在jvm层面,实现加解锁监视器monitor enter和monitor exit时,是有强制去将线程的本地内存值更新到主存和强制从主存读取最新值更新到线程的本地内存,这样才保证了可见性,在后面volatile处验证可见性)既能保证可见性、原子性和有序性(这里实现有序性并不是通过禁止指令重排去实现,是根据通过加锁机制去保护共享变量,比如在懒汉单例模式中,在判断实例!=null之前,通过加锁获取实例,那么对于new Object()是否进行指令重排又有什么关系呢),而volatile关键字只能保证可见性(另外final关键字也能保证可见性)和有序性。和数据库事务一样,要保证事务的安全性则必须满足事务的四大特性(原子性、一致性、隔离性和持久性),那么并发编程也一样,要保证并发代码的安全性则必须满足这三大特性,此时就称其为线程安全(所以,volatile不能保证线程安全)。

线程不安全的问题

public class Demo implements Runnable {

	private long count = 0;
	
	@Override
	public void run() {
		for(int i=0;i<100000;i++) {
			count++;
		}
	}
	
	public static void main(String[] args) {
		Demo demo = new Demo();
		Thread t1 = new Thread(demo);
		Thread t2 = new Thread(demo);
		t1.start();
		t2.start();
		// 等t1、t2执行完毕之后再执行主线程
		try {
			t1.join();
			t2.join();
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		System.out.println(demo.count);
	}
}

执行结果:

8db3cc00c0f14785bcce225e4a37da36.png

分析:

上面的代码想实现的是,用2个线程分别对一个long变量执行累加10W次,预期的结果是20W,但是可以看到执行结果远小于20W;

问题就在于count++不是原子操作(同事务原子性,一个操作不可再切分称为原子操作),它其实包含了如下3个操作:

1、从寄存器中读取count的值;

2、将count加一;

3、将count的值重新写回寄存器。

我们知道,所谓的多线程,并不是真正意义上多个任务同时在执行,而是一个任务执行一会,CPU在快速的切换,让你感觉它就是多个任务在同时进行。假如t1执行到步骤1之后,读取到的count=0,此时被CPU剥夺运行权,切换成t2执行,此时t2读取到的值和t1是一样的,接下来t1执行加一count=1,t2执行加一count还是等于1,所以写回内存的count值都是1,接着再往下执行,那么count的值肯定就不等于20W了(ps:话说,是不是运行结果越大,表示CPU性能越差?几乎是第一个线程执行完毕,第二个线程才开始执行,CPU切换线程太慢了,这句话别当真,值得斟酌)。

以上现象就称为线程不安全。

ReentrantLock对象

ReentrantLock是Lock接口的一个实现类,可以实现上锁和解锁的操作,它是一种悲观锁,具有可重入性和可中断性。

可重入性:指某一线程,不管方法之间如何相互调用,可以重复地获取已经持有的锁(比如持有同一把锁的方法A和B,A调用B不会造成死锁);

可中断性:如果是不可中断,假如一个线程获取到锁之后,另一线程需要获取锁就只能选择等待,直到持有锁的线程释放锁,反之,在等待锁的线程可以被中断。

ReentrantLock常用方法与构造方法如下

void lock():获取锁,如果锁同时被另一个线程拥有则发生阻塞;

boolean tryLock():尝试获取锁,如果获取到锁就返回true,否则返回false;

boolean tryLock(long time, TimeUnit unit):当在指定时间内获取不到锁,则放弃获取锁,返回false,否则返回true;

void unlock():释放锁,如果当前线程没有持有锁对象,调用此方法会抛出异常:IllegalMonitorStateException(监视器非法状态异常,监视器指的是锁的监视器,即当前资源没有持有锁资源),每获取一次锁(不管是lock()获取锁还是tryLock()获取锁),尽管它是可重入的,一定对应一次unlock()解锁,否则锁将永远得不到释放;

void lockInterruptibly():获取可中断的锁;

public ReentrantLock():无参构造方法,构造一个可以被用来保护临界区的可重入锁;

public ReentrantLock(boolean fair):构造一个带有公平策略的锁。但是,这一公平的保证将大大降低性能,所以,默认情况下,锁都没有被强制公平。

改造上面的代码,测试lock()与unlock()方法:

public class LockTest implements Runnable {

	private long count = 0;
	// 声明锁.需要保证锁对象一致,不要去创建多个锁对象或者在run()方法中创建
	private Lock lock = new ReentrantLock();

	@Override
	public void run() {
        System.out.println(Thread.currentThread().getName() + "开始标记");
		lock.lock();
        System.out.println(Thread.currentThread().getName() + "获取锁执行");
		try {
			for (int i = 0; i < 100000; i++) {
				count++;
			}
		} finally {
			// 一定要保证锁得以释放,否则其它线程将永远阻塞
			lock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放锁");
		}
	}

	public static void main(String[] args) {
		LockTest demo = new LockTest();
		Thread t1 = new Thread(demo);
		Thread t2 = new Thread(demo);
		t1.start();
		t2.start();
		// 等t1、t2执行完毕之后再执行主线程
		try {
			t1.join();
			t2.join();
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		System.out.println(demo.count);
	}
}

运行这段代码,结果如下:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAWmVwYWw=,size_20,color_FFFFFF,t_70,g_se,x_16

可以看到结果是预期的200000,且在获取锁后面的代码,线程0是等待线程1执行完毕之后再执行的,于是可以看出lock()方法使ReentrantLock表现成悲观锁,并且不可中断。

改造上面的代码,测试tryLock()与unlock()方法:

public class LockTest2 implements Runnable {

	private long count = 0;
	// 声明锁.需要保证锁对象一致,不要去创建多个锁对象或者在run()方法中创建
	private Lock lock = new ReentrantLock();
	
	@Override
	public void run() {
        System.out.println(Thread.currentThread().getName() + "开始获取锁");
		boolean hasLock = lock.tryLock();
		if(!hasLock) {
			// 如果没有获取到锁,直接返回
			return;
		}
		System.out.println(Thread.currentThread().getName() + "获取锁,开始执行");
		try {
			for (int i = 0; i < 100000; i++) {
				count++;
			}
		} finally {
			// 一定要保证锁得以释放,否则其它线程将永远阻塞
			lock.unlock();
		}
	}
	
	public static void main(String[] args) {
		LockTest2 demo = new LockTest2();
		Thread t1 = new Thread(demo);
		Thread t2 = new Thread(demo);
		t1.start();
		t2.start();
		// 等t1、t2执行完毕之后再执行主线程
		try {
			t1.join();
			t2.join();
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		System.out.println(demo.count);
	}

}

运行结果:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAWmVwYWw=,size_18,color_FFFFFF,t_70,g_se,x_16

可以看到,只有一个线程获取到锁并执行,当另一个线程尝试获取锁的时候,因为锁已被另一个先线程获取,所以没能获取到锁,返回false并return(这里如果);将run()方法再次改造如下:

@Override
public void run() {
		System.out.println(Thread.currentThread().getName() + "开始获取锁");
		while(true) {
			if(lock.tryLock()) {
				break;
			}
		}
		System.out.println(Thread.currentThread().getName() + "获取锁,开始执行");
		try {
			for (int i = 0; i < 100000; i++) {
				count++;
			}
		} finally {
			// 一定要保证锁得以释放,否则其它线程将永远阻塞
			lock.unlock();
		}
}

 运行结果如下:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAWmVwYWw=,size_20,color_FFFFFF,t_70,g_se,x_16

由上2个示例可以证明,tryLock()方法也是获取锁,如果能获取到锁,返回true,获取不到锁则返回false,且如果某个线程获取不到锁,将会以不持锁的状态继续运行。

改造上面的代码,测试可重入性:

public class LockTest3 implements Runnable{

	private long count = 0;
	// 声明锁.需要保证锁对象一致,不要去创建多个锁对象或者在run()方法中创建
	private Lock lock = new ReentrantLock();
	
	@Override
	public void run() {
		lock.lock();
		System.out.println(Thread.currentThread().getName() + "获取锁,开始执行");
		System.out.println(Thread.currentThread().getName() + "再次获取锁");
		lock.lock();
		System.out.println(Thread.currentThread().getName() + "再次获取锁成功,开始执行");
		try {
			for (int i = 0; i < 100000; i++) {
				count++;
			}
		} finally {
			// 一定要保证锁得以释放,否则其它线程将永远阻塞
			lock.unlock();
			lock.unlock();
		}
		
	}
	
	public static void main(String[] args) {
		LockTest3 demo = new LockTest3();
		Thread t1 = new Thread(demo);
		Thread t2 = new Thread(demo);
		t1.start();
		t2.start();
		// 等t1、t2执行完毕之后再执行主线程
		try {
			t1.join();
			t2.join();
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		System.out.println(demo.count);
	}
	
}

运行结果如下:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAWmVwYWw=,size_20,color_FFFFFF,t_70,g_se,x_16

可以看到,每个线程都得以加锁执行。假如它是不可重入的,当线程0获取到锁之后,再次获取锁时,由于锁已被持有,还未释放,那么第二次获取锁将永远获取不到锁(死锁),由于可重入,那么此线程有权再次获取这把锁,此外因为2次获取锁,那么对应的就用2次unlock(),假如不在同一方法内,也需要每获取一次锁就对应一次unlock();

上面是在同一方法内验证可重入,此外,可重入不要求是同一个类,也不要求不要求是同一个方法。比如加锁方法A调用加锁方法B,或者A类的加锁方法method()调用B类的加锁方法meyhod()。总结起来就是:同一个方法是可重入、可重入不要求是同一个类、可重入不要求是同一个方法。

改造上面的代码,测试lockInterruptibly()方法的可中断性:

public class LockTest5 {

	public static void main(String[] args) {
		
		Lock lock = new ReentrantLock();
		
		Thread t = new Thread(() -> {
			System.out.println("线程tt开始获取锁");
			try {
				lock.lockInterruptibly();
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
				System.out.println("tt线程中断");
				return;
			}
			
			try {
				System.out.println("tt释放锁");
			} finally {
				lock.unlock();
			}
		}, "tt");
		// 主线程在tt线程执行前持有锁
		lock.lock();
		t.start();
		// 标记tt线程中断(此时主线程未释放锁)
		t.interrupt();
	}
	
}

执行结果:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAWmVwYWw=,size_20,color_FFFFFF,t_70,g_se,x_16

可以看到tt线程被标记可中断之后,因获取不到锁而抛出中断异常,而不会一直等待持有锁的线程去释放锁。

Condition对象

Condition位于java.util.concurrent包下,是一个多线程协调通信的工具类,为持有锁的线程实现等待通知机制的条件对象。

比如有这么一个场景,某个线程持有锁之后,由于某些业务原因,因为不满足某些条件,我们不想去执行他,但是它又持有锁,导致其他线程一直处于阻塞状态,此时我们就需要去释放锁,并让当前线程进入阻塞状态,让其他线程继续执行,当满足某些条件之后,当前阻塞线程被唤醒后重新竞争锁执行。

这一现象称为等待通知机制,在java一般有2种实现方式:

1、基于wait/notify方法结合synchronized关键字来实现;

2、Condition结合Lock来实现;

一般都通过Lock对象去创建Condition对象,如下:

Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();

它有以下常用方法:

void await() throws InterruptedException:将当前线程加入条件等待集中并释放锁;

boolean await(long time, TimeUnit unit) throws InterruptedException:同await()功能,在指定时间内如果不被唤醒则超时自动释放,并返回false;

void signal():从条件等待集中随机唤醒一个线程,解除其阻塞状态,此时会重新等待获取锁;

void signalAll():从条件等待集中唤醒所有线程,并解除阻塞状态,此时全部线程重新竞争获取锁;

说明及注意:

1、Condition的所有方法都必须在lock()和unlock()之间进行,即获得锁之后解锁之前;

2、可以通过Lock创建多个Condition对象,不同Condition对象之间互不干扰,即不能相互唤醒,这点比Object的notify()灵活得多;

3、如果被阻塞的线程没有设置超时释放,或通过signal()/signalAll()方法手动唤醒,那么它将一直阻塞。

一个简单的示例:

package test;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ConditionTest {

	private static Lock lock = new ReentrantLock();
	
	private static int count = 0;

	public static void main(String[] args) {
		
		Condition condition = lock.newCondition();
		Runnable r = () -> {
			String currentThreadName = Thread.currentThread().getName();
			System.out.println(currentThreadName + "---开始标记---");
			lock.lock();
			System.out.println(currentThreadName + "---获取锁开始执行---");
			try {
				if("A".equals(currentThreadName) || "B".equals(currentThreadName)) {
					System.out.println(currentThreadName + "---被执行阻塞并释放锁,等待被唤醒---");
					// 注意:所有Condition对象的方法都必须在lock()和unlock()之间进行调用,即获取锁之后释放锁之前
					condition.await();
				}else if("E".equals(currentThreadName)) {
					System.out.println(currentThreadName + "---唤醒所有被阻塞的线程---");
					// 注意:所有Condition对象的方法都必须在lock()和unlock()之间进行调用,即获取锁之后释放锁之前
					condition.signalAll();
					// 此时,所有被唤醒的线程重新竞争锁
				}
				System.out.println(currentThreadName + "---执行计数---");
				count++;
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}finally {
				System.out.println(currentThreadName + "---释放锁---");
				lock.unlock();
			}
		};
		
		Thread t1 = new Thread(r, "A");
		Thread t2 = new Thread(r, "B");
		Thread t3 = new Thread(r, "C");
		Thread t4 = new Thread(r, "D");
		t1.start();
		t2.start();
		t3.start();
		t4.start();
		
        // 等t3和t4执行完之后,再去唤醒t1和t2
		try {
			t3.join();
			t4.join();
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		
		Thread t5 = new Thread(r, "E");
		t5.start();
		
        // 等t1、t2和t5执行完之后回到主线程
		try {
			t1.join();
			t2.join();
			t5.join();
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		
		System.out.println(count);
	}
}

执行结果:

A---开始标记---
B---开始标记---
C---开始标记---
A---获取锁开始执行---
A---被执行阻塞并释放锁,等待被唤醒---
D---开始标记---
D---获取锁开始执行---
D---执行计数---
D---释放锁---
C---获取锁开始执行---
C---执行计数---
C---释放锁---
B---获取锁开始执行---
B---被执行阻塞并释放锁,等待被唤醒---
E---开始标记---
E---获取锁开始执行---
E---唤醒所有被阻塞的线程---
E---执行计数---
E---释放锁---
A---执行计数---
A---释放锁---
B---执行计数---
B---释放锁---
5

 通过执行结果可以看到,t1和t2在持有锁之后被阻塞,并释放锁,然后t5去唤醒了所有阻塞的线程(t1和t2),然后被唤醒的所有线程重新竞争锁并执行。

synchronized关键字

synchronized是java提供的一个可以实现锁机制的关键字,它是一种悲观锁,且是一种非公平锁,具有可重入性和不可中断性,同时,不管代码是否异常,synchronized都会自动释放锁。它有如下几种用法:

1、同步代码块:

public class SynchronizedTest {

	private final Object obj1 = new Object();
	private final Object obj2 = new Object();
	private final String obj3 = "lock";
	private final Object obj4 = null;

	public void method() {
		synchronized (this) {
			// 如下,this可以替换成任意锁标识,但是不能为null,也不能为任意基本数据类型
			// 一个方法内支持多个同步代码块,且可以多个嵌套(当然,一般没人会这么写)
			// TODO 执行其他被锁定的代码
			synchronized (this) {
				
			}
		}
		synchronized (obj1) {

		}
		synchronized (obj2) {

		}
		synchronized (SynchronizedTest.class) {

		}
		synchronized (obj3) {
			
		}
		synchronized (obj4) {
			// 因为obj3 == null,所以这里会抛出NPE异常
		}
	}

	
}

2、synchronized修饰普通方法:

// synchronized修饰普通方法
public synchronized void methodA() {
    // TODO 执行其他被锁定的逻辑
}

3、synchronized修饰静态方法:

// synchronized修饰普通方法
public synchronized static void methodA() {
    // TODO 执行其他被锁定的逻辑
}

接下来就讨论,每种写法的含义是什么?以及有什么区别?

在同步代码块形式中,锁标识有this,有new出的对象,有新创建的String字符串,还有*.class。按照它们四者的区别,它们又被分为对象锁和类锁。其中,this,new出的对象以及创建的字符串都是对象锁,*.class是类锁,另外,synchronized修饰普通方法,因为需要对象调用,它归为对象锁,synchronized修饰静态方法,直接用类调用,它归为类锁。

首先,this表示当前对象,new出的对象表示目标类的对象,新创建的字符串也是String对象,对于任意一个java类,不管被new出多少个对象,始终只有一个*.class。

在他们作为synchronized的锁标识的时候,不管怎么花里胡哨的,不管用的是类锁,还是对象锁,万变不离其宗,只要是同一个目标,就是同一把锁,同样,任何时刻,synchronized锁的粒度不会传递和更改。接下来,举证说明一些不容易理解或者容易犯错的特殊场景:

1、多个synchronized嵌套(虽然没人这么写),如果持有的是同一把锁,因为可重入性质,并不会死锁;假如不是同一把锁,某个线程在最外层的synchronized获取到锁之后,最内层的synchronized也只有它能访问到,根本没有其它线程竞争,直到最外层的synchronized被释放,运行结果等效于同一把锁的嵌套,但不是可重入性的表现;

@Override
public void run() {
		synchronized(this) {
			System.out.println(Thread.currentThread().getName() + "开始运行第1个synchronized");
			synchronized (this) {
				System.out.println(Thread.currentThread().getName() + "开始运行第2个synchronized");
			}
		}
		
}

2、一个方法内包含多个synchronized代码块(非嵌套形式),当某个线程优先获得锁运行完第一个代码块之后,会释放锁,然后所有线程重新获取锁,假如还是当前线程获得锁,那么它会进入第二个synchronized代码块,如果被其他线程获得锁,那么获得锁的线程会进入第一个synchronized代码块;

@Override
public void run() {
		synchronized (this) {
			System.out.println(Thread.currentThread().getName() + "***开始运行第1个代码块***");
		}
		
		synchronized (this) {
			System.out.println(Thread.currentThread().getName() + "***开始运行第2个代码块***");
		}
		
}

3、synchronized修饰静态方法和非静态方法的区别:在静态方法上加synchronized,使用的是类锁的形式,在非静态方法上修饰是对象锁的形式。静态方法是定义在公共内存空间的静态区域,当这个方法被定义出来,它的内存空间和地址始终是固定且唯一的,当synchronized修饰静态方法的时候,不管这个类被创建出了多少个对象,去调用静态方法都是一把锁,会互斥,反之,每new出一个对象,都会被分配一块独立的内存空间,则不是一把锁。

public class SynchronizedClassTest implements Runnable {

	static SynchronizedClassTest instance1 = new SynchronizedClassTest();
	static SynchronizedClassTest instance2 = new SynchronizedClassTest();
	
	@Override
	public void run() {
		method();
		
	}
	
	public synchronized static void method() {
		System.out.println(Thread.currentThread().getName() + "***开始运行***");
		try {
			Thread.sleep(2000);
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		System.out.println(Thread.currentThread().getName() + "***运行结束***");
	}
	
	public static void main(String[] args) throws InterruptedException {
		// 如果不加static修饰,就是对象锁形式,instance1和instance2不是同一个锁对象
		// 那么线程1和线程2并不会互斥,synchronized类似于失效的现象(其实是因为不是同一把锁的原因)
		// 如果加了static修饰,就是类锁形式,验证了一个概念:java类可能有很多个对象,但是只有一个Class对象
		// 那么t1和t2就是同一把锁,执行结果符合预期
		// tips:需要在全局的层面,不仅仅是对象的层面,使用同步方法,考虑类锁
		Thread t1 = new Thread(instance1, "线程1");
		Thread t2 = new Thread(instance2, "线程2");
		t1.start();
		t2.start();
		t1.join();
		t2.join();
		System.out.println("运行完毕");
	}

}

运行结果:当有static修饰method()方法时,t1和t2线程会互斥,当没有static修饰的时候,t1和t2不会互斥,并不是因为synchronized失效,实质上就是因为不是用一把锁。

Lock与synchronized对比总结

1、怎么选择?如果可以的话,都不要选择,直接使用java的java.util.concurrent包下的各种工具类;

2、如果一定要使用,尽量选择synchronized,降低编程复杂度,避免出错,稳定性是首要的;

3、在使用锁的过程中,需要注意锁的粒度,作用域不宜过大,比如synchronized尽量套入少的代码;

4、如果特别需要Lock/Condition结果提供的独有特性时,才使用Lock/Condition结构。

volatile关键字

首先,volatile关键字,只能用于修饰全部变量。它主要有以下2个作用:

1、保证可见性(并不能保证原子性);

2、保证有序性;

可见性说明

public class VolatileTest {

	private volatile boolean flag = true;
	
	public void refresh() {
		flag = false;
		System.out.println(Thread.currentThread().getName() + "---修改flag值---");
	}
	
	public void load() {
		System.out.println(Thread.currentThread().getName() + "---开始执行---");
		long i = 0;
		while(flag) {
			i++;
			// TODO 其他业务逻辑
		}
		System.out.println(Thread.currentThread().getName() + "---跳出循环:" + i);
	}
	
	public static void main(String[] args) {
		
		VolatileTest test = new VolatileTest();
		Thread t1 = new Thread(()->{
			test.load();
		}, "计数线程");
		t1.start();
		
		try {
			// 尽可能的让t1先执行起来(这里简便书写,一般情况下t1是一定能执行起来的)。
			// 说明:通过前面的学习可以知道,这里并不能完全100%的保证t1一定会执行起来
			// 因为决定t1能否执行是通过t1是否被CPU分配资源所决定
			// 真正能保证t1一定执行起来应该根据t1的线程状态去判断
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		
		Thread t2 = new Thread(()->{
			test.refresh();
		}, "修改flag线程");
		t2.start();
	}
	
}

执行结果:

计数线程---开始执行---
修改flag线程---修改flag值---
计数线程---跳出循环:3082445390

可以通过结果看到,"计数线程"最后读取到flag==false(由"修改flag线程"修改)而跳过while循环,假如没有volatile修饰,那么"计数线程"会一直进入while循环。

原因分析

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAWmVwYWw=,size_18,color_FFFFFF,t_70,g_se,x_16

上图是java内存模型,也称为共享内存模型,即常说的JMM(java memory model)。JMM决定一个线程对共享变量的写入时,能对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
1、首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。

2、 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

所以,上面的代码中,如果没有volatile修饰flag变量,"计数线程"是不会去读取主内存中的flag值的。

volatile实现可见性的简单剖析

首先,我们知道java代码被执行大致需要经过以下几个过程:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBAWmVwYWw=,size_20,color_FFFFFF,t_70,g_se,x_16

volatile保证了修饰的共享变量在转换为汇编语言时(经历上述过程中),会加上一个以lock为前缀的指令,当CPU发现这个指令时,立即会做两件事情:

1、将当前内核中线程工作内存中该共享变量刷新到主存;

2、通知其他内核里缓存的该共享变量内存地址无效(通过MESI协议);

MESI协议:Intel开发的缓存一致性协议。大致思路是:当CPU写数据时,如果发现操作的变量是共享变量时,即在其他CPU中也存在该变量的副本,那么他会发出信息通知其他CPU将该变量的内存地址无效,当其他线程需要使用这个变量时,如内存地址失效,那么它们会在主存中重新读取该值,因此在读取volatile修饰的变量时总会返回最新写入的值。

synchronized验证可见性

public class VolatileTest {

	private  boolean flag = true;
	
	private Lock lock = new ReentrantLock();
	
	public synchronized boolean getFlag() {
		return flag;
	}

	public void refresh() {
		this.flag = false;
		System.out.println(Thread.currentThread().getName() + "---修改flag值---");
	}
	
	public void load() {
		System.out.println(Thread.currentThread().getName() + "---开始执行---");
		long i = 0;
		while(true) {
//			lock.lock();
			if(!getFlag()) {
//				lock.unlock();
				break;
			}
			i++;
			// TODO 其他业务逻辑
//			lock.unlock();
		}
		System.out.println(Thread.currentThread().getName() + "---跳出循环:" + i);
	}
	
	public static void main(String[] args) {
		
		VolatileTest test = new VolatileTest();
		Thread t1 = new Thread(()->{
			test.load();
		}, "计数线程");
		t1.start();
		
		try {
			// 尽可能的让t1先执行起来(这里简便书写,一般情况下t1是一定能执行起来的)。
			// 说明:通过前面的学习可以知道,这里并不能完全100%的保证t1一定会执行起来
			// 因为决定t1能否执行是通过t1是否被CPU分配资源所决定
			// 真正能保证t1一定执行起来应该根据t1的线程状态去判断
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		
		Thread t2 = new Thread(()->{
			test.refresh();
		}, "修改flag线程");
		t2.start();
	}
	
}

运行代码,同样可以看到和volatile一样的效果,正常跳出循环,同理,将上面的加解锁代码换成Lock是一样的效果(被注释部分)。

final关键字可见性:由于被定义为final的变量是不能被更改的,在上面的代码,定义一个Map容器,如下:

final Map<String, Boolean> flagMap = new HashMap<>();

 去替代boolean flag变量,可以得到同样的验证效果(这里省略代码)。

 有序性说明

//划重点了 **volatile**
private volatile static LazyLoadBalancer loadBalancer;

public static LazyLoadBalancer getInstance() {
    if (loadBalancer == null) {
        synchronized (LazyLoadBalancer.class) {
            if (loadBalancer == null) {
                loadBalancer = new LazyLoadBalancer();
            }
        }
    }
    return loadBalancer;
}

这是一段"饿汉式"的单利模式,其中对象用volatile修饰。

针对于new LazyLoadBalance();,它大致会经历以下3个过程:

1、分配对象的内存空间;

2、调用构造方法;

3、将内存空间赋值给当前实例(执行完这步,当前实例就不为null了);

由于指令重排的原因,上面的执行顺序不一定是1-2-3,假如构造方法中逻辑很复杂,可能会将当前实例先创造出来,然后再去执行构造方法中的其他逻辑,那么执行顺序可能会是1-2-3-2。

在高并发下,线程A执行了new LazyLoadBalance(),此时并未完全实例化出对象,由于指令重排的原因,线程B提前读取到当前实例!=null,然后直接返回,那么在后续的操作过程中,由于该实例还未完全处理化,就会导致一系列的连锁问题。

指令重排完全是由编译器或CPU决定的,但是指令重排不会对结果造成任何影响,比如上面的3个过程,不论怎么重排,不可能会是2-1-3或者3-2-1的顺序。在单线程或者私有变量中,由于程序是每行代码自上而下运行,所以在每次new Object()之后,一定会是等new Object()完全成功之后,才会执行后面的代码,使用这个实例则不会出现上面的问题。但是针对上面的代码,多线程中,LazyLoadBalancer又是共享变量,其中可能会有的线程if (loadBalancer == null)结果为false,导致提前读取到由于指令重排并未完全初始化的实例。

与synchronized等加锁机制的区别

观点1、首先,加锁机制保证的可见性,是在互斥的基础上保证可见性。因此在访问volatile修饰的变量时,就不会执行线程阻塞,效率上是远高于加锁机制的。

观点2、写入volatile变量类似于退出同步代码块,而读取volatile变量类似于进入同步代码块,因此volatile它也是一种同步机制,是一种很轻量级的同步机制;

观点3:虽然volatile很方便,但是也存在很大的局限性,针对于一写多读的并发场景,volatile能很好且有效的解决同步问题,但是如果是多写场景,它则无法解决线程安全问题,因为它只能保证可见性和有序性,并不能保证原子性,所以多写并发场景还需要其他加锁机制去实现;

综上,适用volatile的场景:

1、需要防止指令重排的时候;

2、仅仅是一写多读的并发场景;

3、修饰的变量通常用作某个操作完成、发生中断或者状态的标志。

AtomicInteger

1、顾名思义,原子性的integer,它是concurrent包下提供的并发编程工具类,除此之外还有AtomicLong、AtomicBoolean等;

2、它是基于CAS(compare and swap,即乐观锁原理)原理开发;

CAS原理:它包含3个值,当前内存值(V),预期值(A)以及待更新的值(B)。实现逻辑是将值V与值A比较,若匹配(这里的匹配是符合条件的规则,并不一定是相等),则将值B重新写回内存,否则不做任何处理或进行重试后再次处理(AtomicInteger等由于重试的原因,所以也称为自旋锁,概念太多,草草哒)。

用sql中的乐观锁where version = version + 1,就好理解。比如实现线程安全的+1自增操作,当前内存值是1,预期结果是2,此时只有一个线程执行+1,那么待更新值是2,如果满足A-1=V,则将B写回内存。如果由于并发,内存值已经被另一个线程修改为2,此时当前线程预期执行结果是2,那么就不满足A-1=V,则不会将新值B写回去,但是会进行重试,执行2+1=3的操作,保证原子性的+1。

3、乐观锁并不是加锁机制,不会造成线程阻塞,所以在一些场景下,使用concurrent.atomic包下的工具有更好的效率;

4、在jdk1.8之后,concurrent.atomic包下提供一个新的可以保证原子性的类LongAadder。由于AtomicLong相关类在大量并发下需要不停重试去满足原子性,这样也会导致效率偏低下,而LongAdder则是利用空间换时间的方式,在进行自增或指定增长值的时候,会先尝试CAS,如果不成功,则放弃CAS,转而分配一个Cell类去记录本次操作(这里是一个懒加载,如果每次尝试CAS都成功,也就永远不会初始化Cell类数组),在获取最终值的时候把这些改变记录和原始值统计起来,类似于分段锁的操作。它有以下常用方法:

public LongAdder():显示声明无参构造函数,初始化一个值为0的对象;

void add(long x):指定一个增长的值(传递负数则是减少);

void increment():递增1,等价于调用add(1L);

void decrement():递减1,等价于调用add(-1L);

long sum():获得最终的执行结果,也就是将Cell数组和初始值统计;

void reset():重置LongAdder计数器;

long sumThenReset():获得统计结果并重置;

同时有个类似的类LongAccumulator,与LongAdder类的区别是,LongAccumulator可以自定义计算规则;

说明:sum()和reset()并不是线程安全的,比如调用sum()的时候,此时可能还有其他线程在修改Cell数组,所以它统计的仅仅是调用时刻的值,也就造成它的结果并不准确;所以它适用的场景仅仅是需要一个计数场景,或者已经明确没有并发的统计,比如当所有统计线程都执行完毕并回到主线程,但是对于结果没有那么精确的要求的场景,如果需要精确的结果需要加锁执行或者使用其他工具,但是它在大并发下的效率是远高于AtomicIntger等的。如果需要一个精确的编号场景,那么还是需要使用AtomicInteger等,或者加锁的LongAdder,只能牺牲性能去保证结果的正确性。

5、AtomicInteger和AtomicLong可以用LongAadder代替,但是AtomicBoolean等是没有代替的,所以需要根据适合的场景选择适合的工具类。

一个简单的AtomicInteger使用示例(也是一个常见面试题),2个线程交替打印奇偶数,如下:

/**
 * 2个线程交替打印奇偶数
 * */
public class PrintOddAndEven {

	// 最大打印范围
	private static final int MAX_NUM = 1000;
	
	// 初始值为0
	private static AtomicInteger atomicInteger = new AtomicInteger(0);
	
	public static void main(String[] args) {
		
		Thread t1 = new Thread(() -> {
			while(true) {
				int i = atomicInteger.get();
				if(i > MAX_NUM) {
					break;
				}
				if(i%2 == 0) {
					System.out.println("偶数线程:" + i);
					atomicInteger.incrementAndGet();
				}
			}
		});
		t1.start();
		
		Thread t2 = new Thread(() -> {
			while(true) {
				int i = atomicInteger.get();
				if(i > MAX_NUM) {
					break;
				}
				if(i%2 == 1) {
					System.out.println("奇数线程:" + i);
					atomicInteger.incrementAndGet();
				}
			}
		});
		t2.start();
	}
}

ThreadLocal

有这么一个场景。需要一个全局的共享容器,但是对于每个线程又是相互隔离、独立的,即A线程访问这个共享容器只能获取到A线程的数据,B线程访问这个共享容器只能获取到B线程的数据,他们之间不会相互影响。

ThreadLocal就是针对这一场景提供的一个类,所属java.lang包。

它的实现机制是:每个线程内部都会维护一个类似于HashMap(不是HashMap类,是一个称为哈希映射(hash map)的对象)的对象,称为ThreadLocalMap,里面包含了若干个Entry对象,每个Entry对象包含一个Key、Value属性(K-V键值对结构),Key是一个ThreadLocal实例,Value是一个线程持有的对象,Entry的作用是为其所属线程建立起一个ThreadLocal实例与一个线程持有的对象之间的对应关系。

它有以下常用方法:

T get():得到当前线程持有的对象;

void set(T value):为当前线程持有的对象设置一个新值;

void remove():移除当前线程持有的对象;

static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier):创建一个局部线程变量,其初始值通过调用给定的supplier生成。

public class ThreadLocalTest {

	private static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>();
	
	public static void main(String[] args) {
		
		new Thread(() -> {
			int x = 100;
			threadLocal.set(x);
			try {
				// 尽可能的让其他线程执行完后再读取ThreadLocal中的值
				// 说明:这里并不能保证其他线程一定被执行(这里简单示例)
				// 线程执行与否,是根据是否获取CPU资源去决定
				// 当该线程重新被唤起之前,其他线程不一定能得到资源
				// 保证100%符合预期是需要根据线程状态去判断的
				Thread.sleep(3000);
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
			System.out.println(Thread.currentThread().getName() + "-线程持有的对象值:" + threadLocal.get());
		}, "A线程").start();;
		
		new Thread(() -> {
			int x = 101;
			threadLocal.set(x);
			System.out.println(Thread.currentThread().getName() + "-线程持有的对象值:" + threadLocal.get());
		}, "B线程").start();;
	}
}

执行结果:

B线程-线程持有的对象值:101
A线程-线程持有的对象值:100

通过执行结果可以看到,A和B线程在ThreadLocal中的值并没有被相互污染。

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
智慧校园整体解决方案是响应国家教育信息化政策,结合教育改革和技术创新的产物。该方案以物联网、大数据、人工智能和移动互联技术为基础,旨在打造一个安全、高效、互动且环保的教育环境。方案强调从数字化校园向智慧校园的转变,通过自动数据采集、智能分析和按需服务,实现校园业务的智能化管理。 方案的总体设计原则包括应用至上、分层设计和互联互通,确保系统能够满足不同用户角色的需求,并实现数据和资源的整合与共享。框架设计涵盖了校园安全、管理、教学、环境等多个方面,构建了一个全面的校园应用生态系统。这包括智慧安全系统、校园身份识别、智能排课及选课系统、智慧学习系统、精品录播教室方案等,以支持个性化学习和教学评估。 建设内容突出了智慧安全和智慧管理的重要性。智慧安全管理通过分布式录播系统和紧急预案一键启动功能,增强校园安全预警和事件响应能力。智慧管理系统则利用物联网技术,实现人员和设备的智能管理,提高校园运营效率。 智慧教学部分,方案提供了智慧学习系统和精品录播教室方案,支持专业级学习硬件和智能化网络管理,促进个性化学习和教学资源的高效利用。同时,教学质量评估中心和资源应用平台的建设,旨在提升教学评估的科学性和教育资源的共享性。 智慧环境建设则侧重于基于物联网的设备管理,通过智慧教室管理系统实现教室环境的智能控制和能效管理,打造绿色、节能的校园环境。电子班牌和校园信息发布系统的建设,将作为智慧校园的核心和入口,提供教务、一卡通、图书馆等系统的集成信息。 总体而言,智慧校园整体解决方案通过集成先进技术,不仅提升了校园的信息化水平,而且优化了教学和管理流程,为学生、教师和家长提供了更加便捷、个性化的教育体验。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值