[Java并发] 1. 线程同步(同步器)
文章目录
- [Java并发] 1. 线程同步(同步器)
- 一、synchronized关键字
- 1. 给对象加锁
- 2. synchronized对方法加锁, 同步方法和非同步方法是否可以同时调用
- 3. 脏读问题
- 4. synchronized是可重入锁
- 5. 出现异常默认情况锁会被释放
- 6. 引用变量指向对象的改变对锁的影响
- 7. 不要将字符串常量作为锁定对象
- 8. 同步代码中的语句越少越好
- 二、volatile关键字
- 三、ReentrantLock可重入锁
- 四、ThreadLocal 线程局部变量
一、synchronized关键字
对于synchronized的理解:
由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类。
1. 给对象加锁
1.1 new一个对象作为锁
synchronized(Object)
对括号内的对象加锁,任何线程要执行synchronized
代码块中的代码,都必须要先拿到该对象的锁,当代码块执行完毕时,锁就会释放,被其他线程获取。
public class T {
private int count = 10;
private final Object lock = new Object(); // 锁对象
public void m() {
synchronized (lock) { // 任何线程要执行下面的代码,都必须先拿到lock锁,锁信息记录在堆内存对象中的,不是在栈引用中
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
// 当上述synchronized代码块执行完毕后,锁就会被释放,然后被其他线程获取
}
}
注意:synchronized(lock)是锁住堆内存中的lock指向的Object对象,而引用变量lock是位于栈内存中的。
1.2 直接锁定自身对象
在1.1中,每次使用锁都新建一个毫无其他功能的锁对象比较麻烦,因此我们可以直接对this
对象加锁,即synchronized(this)
。
public class T {
private int count = 10;
public void m() {
synchronized (this) { // 任何线程要执行下面的代码,必须先拿到this锁
// synchronized锁定的不是代码块,而是this对象
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
}
1.3 synchronized修饰方法
若整个方法内所有代码都被synchronized
修饰,则可以使synchronized
关键字修饰整个方法。
public class T {
private int count = 10;
public synchronized void m() { // 等同于synchronized(this),锁定当前堆内存对象
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
1.4 synchronized锁定静态方法
类中静态方法和静态属性属性不需要new一个对象就可以访问,没有new出来,就没有this引用的存在,所以当锁定一个静态方法时,相当于锁定的是当前类的class对象。
public class T {
private static int count = 10;
public static synchronized void m() {//等同于synchronized(T.class)
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
// 上边m()方法与下边mm()方法等价
public static synchronized void mm() {
synchronized (T.class) {
// 这里不能使用synchronized(this),因为静态方法不需要实例对象即可访问
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
}
1.5. synchronized锁住线程的run方法
public class T implements Runnable{
private int count = 10;
@Override
public /*synchronized*/ void run() {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
public static void main(String[] args) {
T t = new T();
for (int i = 0; i < 5; i++) {
new Thread(t).start(); //这里new的所有线程的锁住的是同一个上边的t对象
}
}
}
run
方法不加synchronized
:因为不保证原子性,每个线程在执行count--
和输出操作之间,可能有别的线程来执行count--
,导致前后数据不一致。
加上synchronized
关键字:相当于是一个原子操作,一个run
方法执行完毕释放了锁,下一个线程才能拿到锁执行run
方法。
2. synchronized对方法加锁, 同步方法和非同步方法是否可以同时调用
public class T {
public synchronized void m1() {
System.out.println(Thread.currentThread().getName() + "m1 start...");
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "m1 end...");
}
public void m2() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "m2 ...");
}
public static void main(String[] args) {
T t = new T();
new Thread(() -> t.m1(), "t1").start();//Java8中的lamba表达式
new Thread(() -> t.m2(), "t2").start();
// new Thread(t::m1, "t1").start(); //更简洁的写法
// new Thread(t::m2, "t2").start();
/**
new Thread(new Runnable() { //最原始的写法
@Override
public void run() {
t.m1();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
t.m2();
}
}).start();
*/
}
}
同步方法与非同步方法是可以同时调用的。只有synchronized修饰的方法在运行过程中才需要申请锁,普通方法是不需要申请的。在同步方法m1()执行的同时,非同步方法m2()也在执行
3. 脏读问题
业务代码中,对业务写方法加锁,而对业务读方法不加锁,容易产生脏读问题(dirty read)。
脏读,不可重复读,幻读
import java.util.concurrent.TimeUnit;
public class Account {
String name;
double balance;//账户余额为成员变量 默认为0.0
public synchronized void set(String name, double balance) {//写操作
this.name = name;
//下面这段是为了放大在this.name = name与this.balance = balance的执行间可能有别的业务代码执行的情形,比如getbalance(),因为这里它是非锁定方法仍然可以访问name
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.balance = balance;
}
public /* synchronized */ double getBalance(String name) {//读操作
return this.balance;
}
public static void main(String[] args) {
Account a = new Account();
new Thread(() -> a.set("张三", 100.0)).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a.getBalance("zhangsan"));//0.0
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a.getBalance("zhangsan"));//100.0
}
}
set
方法在初始化时会休眠2s,我们调用set
方法后1s读取余额,显示为0,再过2s后余额才变为100.0,因此允不允许脏读?要根据实际业务场景斟酌使用
4. synchronized是可重入锁
4.1 一个同步方法可以调用另一个同步方法
一个同步方法可以调用另外一个同步方法:若一个线程已抢到某对象的锁,再申请时仍然会得到该对象的锁。因为这是在同一个线程以内,无非就是给锁上的数字加一(同一线程,同一把锁)
import java.util.concurrent.TimeUnit;
public class T implements Runnable {
@Override
public synchronized void run() {
System.out.println("m1 start...");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
m2();// 在同步方法m1()中调用同步方法m2(),不会发生死锁,因为这是在同一线程内的调用
System.out.println("m1 end");
}
synchronized void m2() {
System.out.println("m2 start");
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m2 end");
}
public static void main(String[] args) {
T t = new T();
new Thread(t::run).start();
}
}
程序输出如下,没有发生死锁,且m1()
方法会等待m2()
方法结束后继续运行,说明这是函数调用,而非线程并行。
m1 start
m2 start
m2 end
m1 end
4.2 子类的同步方法可以调用父类的同步方法
子类的同步方法可以调用父类的同步方法也不会发生死锁,两个方法锁住的this
指向的都是同一个子类对象。
import java.util.concurrent.TimeUnit;
public class T {
// 父类同步方法
synchronized void m2() {
System.out.println("father method start");
System.out.println("father method lock:" + this);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("father method end");
}
}
class TT extends T {
// 子类同步方法
@Override
synchronized void m1() {
System.out.println("child method start");
System.out.println("child method lock:" + this);
super.m();
System.out.println("child method end");
}
public static void main(String[] args) {
TT tt = new TT();
new Thread(tt::m1).start();
}
}
程序输出结果如下,没有发生死锁,且m1()
方法会等待m2()
方法结束后继续运行,说明这是函数调用,而非线程并行; 另外也可以看到父子的同步方法持有的是同一把锁。
child method start
child method lock:thread01.TT@2dd5c6ac
father method start
father method lock:thread01.TT@2dd5c6ac
father method end
child method end
5. 出现异常默认情况锁会被释放
若synchronized
修饰的代码块中出现异常,线程进行异常处理后会马上释放锁(与ReentrantLock
正相反).
import java.util.concurrent.TimeUnit;
public class T {
int i = 0;
// 同步方法,计数到5抛出异常
synchronized void m() {
System.out.println(Thread.currentThread().getName() + " start");
while (true) {
i++;
System.out.println(Thread.currentThread().getName() + ": " + i);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 计数到5抛出异常
if (i == 5) {
int error = 1 / 0;
}
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m, "线程1").start();
new Thread(t::m, "线程2").start();
}
}
程序执行结果:线程1抛出异常后马上释放锁,锁被线程2抢到并开始执行。
解决方法: 使用try-catch
捕获异常。
try{
if (i == 5) {
int error = 1 / 0;
}
}catch(Exception e){
System.out.println("除法溢出");
}
6. 引用变量指向对象的改变对锁的影响
synchronized
锁住的是堆中o
对象的实例,而不是o
对象的引用,synchronized
是针对堆中o
对象的实例进行计数。
- 若在程序运行过程中,,引用
o
指向对象的属性发生改变,锁状态不变。 - 若在程序运行过程中,引用
o
指向的对象发生改变,则锁状态改变,原本抢到的锁作废,线程会去抢新锁。因此实际编程中常将锁对象的引用用final
修饰,保证其指向的锁对象不发生改变。(final
修饰引用时,该引用所指向的属性可以改变,但该引用不能再指向其他对象)
public class T {
Object o = new Object();
// 该方法锁住的o对象引用没有被设为final
void m() {
synchronized (o) {
while (true) {
System.out.println(Thread.currentThread().getName() + "正在运行");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m, "线程1").start();
// 在这里让程序睡一会儿,保证两个线程得到的o对象不同
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread thread2 = new Thread(t::m, "线程2");
// 改变锁引用,使得线程2也有机会运行,否则一直都是线程1运行
t.o = new Object();
thread2.start();
}
}
程序输出如下,看到主线程睡了3秒之后,线程1和线程2交替运行,他们各自抢到了不同的锁
线程1正在运行
线程1正在运行
线程1正在运行
线程2正在运行
线程1正在运行
线程2正在运行
线程1正在运行
线程2正在运行
...
如果没有改变锁引用,将会一直是线程1在运行。
7. 不要将字符串常量作为锁定对象
因为字符串常量池的存在,两个不同的字符串引用可能指向同一字符串对象。
public class T {
// 两个字符串常量,作为两同步方法的锁
String s1 = "Hello";
String s2 = "Hello";
// 同步m1方法以s1为锁
void m1() {
synchronized (s1) {
while (true) {
System.out.println(Thread.currentThread().getName() + ":m1 is running");
}
}
}
// 同步m2方法以s2为锁
void m2() {
synchronized (s2) {
while (true) {
System.out.println(Thread.currentThread().getName() + ":m1 is running");
}
}
}
public static void main(String[] args) {
T t = new T();
// 输出两个锁的哈希码
System.out.println(t.s1.hashCode());
System.out.println(t.s2.hashCode());
new Thread(t::m1, "线程1").start();
new Thread(t::m2, "线程2").start();
}
}
程序执行结果如下,实际上m1
和m2
其实锁定的是同一对象,即两个字符串常量指向的是同一对象,有一个线程永远得不到锁。
69609650
69609650
线程1:m1 is running
线程1:m1 is running
线程1:m1 is running
线程1:m1 is running
线程1:m1 is running
线程1:m1 is running
这种情况还会发生比较诡异的现象,比如你用到一个类库,在该类库中代码锁定了字符串 “Hello”,但是你看不到源码,然后你在自己代码中也锁定了 “Hello”, 这时就会发生非常诡异的死锁阻塞,因为你的程序和使用到的类库不经意间使用了同一把锁。
8. 同步代码中的语句越少越好
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* synchronized的优化
* 同步代码中的语句越少越好
* 比较m1和m2
*/
public class T {
int count = 0;
synchronized void m1() {
// do something need not sync
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 业务逻辑中只有下面这句需要sync,这时不应该给整个方法上锁
count ++;
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
void m2() {
// do something need not sync
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 业务逻辑中只有下面这句需要sync,这时不应该给整个方法上锁
// 采用细粒度的锁, 可以使用线程争用时间变短,从而提高效率
synchronized(this) {
count ++;
}
// do something need not sync
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
T t = new T();
List<Thread> threads = new ArrayList<Thread>();
for (int i = 0; i < 10; i ++) {
threads.add(new Thread(t::m1, "thread-" + i));
}
threads.forEach((o) -> o.start());
threads.forEach((o) -> {
try {
o.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(t.count);
}
}
二、volatile关键字
volatile关键字, 是一个变量在多个线程间可见
1. volatile的可见性
volatile
关键字向编译器声明该变量是易变的,每次对volatile
关键字的修改会通知给所有相关线程.
- 在Java内存模型JMM中,所有对象以及信息都存放在主内存中(包含堆,栈),而每个线程在CPU中都有自己的独立空间,存储了需要用到的变量的副本。
- 线程对共享变量的操作,都会先在自己CPU中的工作内存中进行,然后再同步给主内存。若不加
volatile
关键字修饰,每个线程都有可能直接从自己CPU中的工作内存读取内存,这样如果另一个线程修改了原变量,该线程却未必知道,从而引起同步问题;而加以volatile
关键字修饰后,每个线程对该变量进行修改后都会马上通知给所有线程。
下面的程序中,running变量存在于主内存的t对象中,当线程t1开通的时候, 会把running值从内存中读到t1线程的工作区,在运行中直接使用这个copy,并不会每次都去读取内存,这样, 当主线程修改running的值后,t1线程感知不到, 所以不会停止运行。
使用volatile, 将会强制所有线程都去对内存中读取running的值, 缓存过期通知
import java.util.concurrent.TimeUnit;
public class T {
/**
* https://www.cnblogs.com/Mushrooms/p/5151593.html
*
* 补充内容:分享点儿知识,内容就是CPU内部的寄存器。就这个程序来说,有两个线程。一个是主线程,
* 一个是自己启动的线程。当自己启动的线程运行时,running这个变量的值会被CPU把值从内存中读到
* CPU中的寄存器(即CPU中的cache)中。为什么这么做呢?因为CPU的速度要比内存的速度快,内存的速
* 度比硬盘快。所以要把running中的数据copy一份到内存中处理。但是,没有加volatile关键字的变
* 量running,当主线程已经把running改为false,自己启动的线程依然不能停下来。因为它读的是CPU
* 中running。主线程改的内存中的running。两个线程读写的变量的存储位置不同。
*
* 而volatile关键字就是为了解决这个问题而出现的。其作用是,当主线程对内存中的变量running修改
* 后,就会通知CPU中的变量running,你那个值已经不是最新的了。这时候,自己启动的线程会重新读一
* 遍内存中的running变量。
*/
volatile boolean running = true; //对比一下有无volatile的情况下,整个程序运行结果的区别
void m() {
System.out.println("m start");
while (running) {
//死循环。只有running=false时,才能执行后面的语句
}
System.out.println("m end");
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 将running变量设为false,观察线程是否被终止
t.running = false;
}
}
运行结果表明,如果不对running
变量加以volatile
修饰,则对running``变量的修改不能终止子线程,说明在主线程中对
running`的修改对子线程不可见.
但是如果在while
死循环体中加入一些语句或sleep
一段时间之后,可见性问题可能会消失,这是因为加入语句后,CPU就可能会出现空闲,并同步主内存中的内容到工作内存,但这是不确定的,因此在这种情况下还是尽量要加上volatile
。
2. volatile不保证原子性
volatile
只能保证可见性,但不能保证原子性。 即只会在读变量的操作进行检查,不会检查写回变量的时候之前读入变量的值是否已经被修改。
volatile
不能解决多个线程同时修改一个变量带来的线程安全问题, 也就是说volatile
不能代替synchronized
。
/*10个线程分别执行10000次count++,count是对象t的成员变量,按理来说最终count=100000,
但是最终每次执行结果都不一样,count一直小于100000,说明volatile不具备原子性*/
import java.util.ArrayList;
import java.util.List;
public class T {
volatile int count = 0;
void m() {
for (int i = 0; i < 10000; i++) {
count ++;//++操作不具备原子性
}
}
public static void main(String[] args) {
T t = new T();
List<Thread> threads = new ArrayList<Thread>();
for (int i = 0; i < 10; i ++) {
threads.add(new Thread(t::m, "thread-" + i));
}
threads.forEach((o) -> o.start());
threads.forEach((o) -> {
try {
//join()方法阻塞调用此方法的线程,直到线程t完成,此线程再继续。通常用于在main()主线程内,等待其它线程完成再结束main()主线程。
o.join();//相当于在main线程中同步o线程,o执行完了,main线程才有执行的机会
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(t.count);
}
}
使用synchronized保证原子性和可见性
使用synchronized
解决,输出count为10000。
int count = 0;
synchronized void m() { //m方法加了synchronized修饰,保证了原子性和可见性
for (int i=0; i<10000; i++) {
count ++ ;
}
}
更高效:使用AtomicXXX类
AtomXXX
类本身方法都是原子性的, 但不能保证多方法连续调用的原子性。
import java.util.concurrent.atomic.AtomicInteger;
AtomicInteger count = new AtomicInteger(0);
/*synchronized*/ void m() { //不需要加锁了
for (int i = 0; i < 10000; i++) {
// 如果加上了if (count.get() < 1000)
// 则在for循环两条语句中间,即这个位置是没有原子性的
count.incrementAndGet(); // 具备原子性,用来替换count++;
}
}
3. volatile与synchronized关键字区别
- volatile 只能保证可见性,效率高
- synchronized 既保证可见性,有保证原子性,效率低
4. 面试题:监控容器内元素个数
题目:写两个线程,线程1添加10个元素到容器中,线程2实时监控元素的个数,当容器中元素个数达到5时,线程2给出提示并立即结束
思路:容器选用ArrayList<Object>
,调用其add()
方法添加元素,调用size()
方法得到容器中元素个数。
方法1 volatile
线程2一直轮询,将容器设为volatile
保证线程间可见性,使线程2可以收到通知。
public class MyContainer {
// 主要容器,设为volatile保证线程间可见性
private volatile List<Object> list = new ArrayList<>();
public void add(Object ele) {
list.add(ele);
}
public int size() {
return list.size();
}
public static void main(String[] args) {
MyContainer container = new MyContainer();
// 线程1,每隔一秒向容器中添加一个元素
new Thread(() -> {
for (int i = 0; i < 10; i++) {
container.add(new Object());
//这个部分可能被线程2抢占
System.out.println("add " + i);
// 每隔一秒添加一个元素
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "线程1").start();
// 线程2,轮询容器内元素个数
new Thread(() -> {
while (true) {
if (container.size() == 5) {
//这个部分可能被线程1抢占
break;
}
}
System.out.println("监测到容器长度为5,线程2立即退出");
}, "线程2").start();
}
}
评价:
- 不够精确: 若当
container.size == 5
还未执行break
时,被其他线程抢占;或container.add()
之后还未打印,就被线程2抢占并判断到container.size == 5
并退出了。 - 损耗性能: 线程2一直在走
while(true)
循环,浪费性能。我们避免用到死循环。
方法2 wait/notifyAll
使用wait/notify机制,当线程1写入5个元素后通知线程2。
① 运用这种方法,必须保证t2先执行,先让t2监听才可以
② wait会释放锁, 而notify与sleep不会释放锁
③ 因此notify之后,t1必须释放锁, t2退出后,也必须notify, 通知t1继续执行
锁的转移过程:
- 先启动
线程2
并使主线程睡2秒以确保线程2
先抢到锁。 线程2
抢到锁后调用wait()
,让其释放锁并阻塞,以确保线程1
获得锁。线程1
抢到锁后开始向容器内添加元素。当线程1
添加了5个元素后调用notify()
通知线程2
并调用wait()
释放锁并阻塞,以确保线程2获得锁。线程2
抢到锁后输出语句并退出,退出之前调用notify()
唤醒线程1
,因为线程2
退出后会释放锁,因此这时不用调用wait()
释放锁。
public class MyContainer {
// 主要容器,因为只有线程1对其进行修改和查询操作,所以不用加volatile关键字
private List<Object> list = new ArrayList<>();
public void add(Object ele) {
list.add(ele);
}
public int size() {
return list.size();
}
public static void main(String[] args) {
MyContainer container = new MyContainer();
final Object lock = new Object(); // 锁对象
// 线程2先启动并进入wait状态,等待被线程1唤醒
new Thread(() -> {
synchronized (lock) {
System.out.println("线程2启动");
if (container.size() != 5) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("监测到容器长度为5,线程2立即退出");
// 线程1唤醒线程2后立刻睡眠了,因此线程2退出前要再次唤醒线程1
lock.notify();
System.out.println("线程2结束");
}
}, "线程2").start();
// 主线程睡2秒钟再创建线程1,确保线程2先得到锁
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 线程1,每隔一秒向容器中添加一个元素
new Thread(() -> {
synchronized (lock) {
for (int i = 0; i < 10; i++) {
container.add(new Object());
System.out.println("add " + i);
// 当容器中元素个数达到5时,唤醒线程2并退出线程1
if (container.size() == 5) {
lock.notify();
// notify()方法不会释放锁,因此即使通知了线程2,也不能让线程2立刻执行
// 所以要先将线程1 wait()住,让其释放锁给线程2,等待线程2退出前再通知唤醒线程1
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 每隔一秒添加一个元素
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}, "线程1").start();
}
}
评价:当不涉及同步,只涉及线程通信的时候,用synchronized+wait/notify
机制就显得太重了,实际编程中常用封装层次更深的类库实现线程间通信.
方法3 CountDownLatch
使用门闩锁CountDownLatch
类锁住线程2,并等待线程1撤去门闩释放线程2。
public class MyContainer {
// 主要容器,因为门闩锁只是一种同步方式,不保证可见性,因此需要用volatile修饰
private volatile List<Object> list = new ArrayList<>();
public void add(Object ele) {
list.add(ele);
}
public int size() {
return list.size();
}
public static void main(String[] args) {
MyContainer container = new MyContainer();
// 门闩锁,构造函数中传入门闩数,使用其countDown()方法撤掉一条门闩
// 当门闩数为0时,门会打开,两个线程都会被执行
CountDownLatch latch = new CountDownLatch(1);
// 线程2先启动并调用await()让其被门闩锁锁住
new Thread(() -> {
System.out.println("线程2启动");
if (container.size() != 5) {
try {
// 让线程被门闩锁锁住,等待门闩的开放,而不是进入等待队列
latch.await();
// 可以指定等待时间
// latch.await(5000, TimeUnit.MILLISECONDS)
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("监测到容器长度为5,线程2立即退出");
}, "线程2").start();
// 主线程睡2秒钟再创建线程1,确保线程2先得到锁
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 线程1,每隔一秒向容器中添加一个元素
new Thread(() -> {
System.out.println("线程1 启动");
for (int i = 0; i < 10; i++) {
container.add(new Object());
System.out.println("add " + i);
// 当容器中元素个数达到5时,撤去一个门闩,打开门闩锁,两个线程都会被执行
if (container.size() == 5) {
latch.countDown();
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "线程1").start();
}
}
评价:
- 门闩锁不涉及锁定,当
count
的值为5时线程1并不会停止运行。 - 使用Latch(门闩)的
await
和countdown
方法代替wait
,notify
来进行通知,通信方式简单, 同时可以指定等待时间。 - 当不涉及同步,只有涉及线程通信的时候,用
synchronized + wait/notify
就太重了,这时应该考虑使用CountDownLatch/cyclicbarrier/semaphore
。
门闩锁CountDownLatch
在框架中使用的非常广泛,如在Spring
框架中,要先实例化所有Properties
和Service
对象后才能实例化Bean
对象。因此我们给初始化Bean
对象的线程上一个两道门闩的门闩锁,初始化完毕所有Properties
对象后撤去一道门闩,初始化完毕所有Service
对象后再撤去一道门闩,两道门闩撤去后,门闩锁打开,创建Bean
的线程开始执行。
三、ReentrantLock可重入锁
1. ReentrantLock替代synchronized
ReentrantLock
可以完全替代synchronized
,提供了一种更灵活的锁。
ReenTrantLock
必须手动释放锁,为防止发生异常,必须将同步代码用try
包裹起来,在finally
代码块中释放锁。
public class T {
ReentrantLock lock = new ReentrantLock();
// 使用ReentrantLock的写法
private void m1() {
// 尝试获得锁
lock.lock(); //等于synchronized(this)
try {
System.out.println(Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
// 使用synchronized的写法
private synchronized void m2() {
System.out.println(Thread.currentThread().getName());
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m1, "t1").start();
new Thread(t::m2, "t2").start();
}
}
2. ReentrantLock获取锁的方法
2.1 尝试锁tryLock()
-
使用
tryLock()
方法可以尝试获得锁,返回一个boolean
值,指示是否获得锁。 -
可以给
tryLock
方法传入阻塞时长,当超出阻塞时长时,线程退出阻塞状态转而执行其他操作。
public class T {
ReentrantLock lock = new ReentrantLock();
void m() {
boolean isLocked = false; // 记录是否得到锁
// 改变下面两个量的大小关系,观察输出
int synTime = 4; // 同步操作耗时
int waitTime = 2; // 获取锁的等待时间
try {
isLocked = lock.tryLock(waitTime, TimeUnit.SECONDS); // 线程在这里阻塞waitTime秒,尝试获取锁
if (isLocked) {
// 若waitTime秒内得到锁,则执行同步操作
for (int i = 1; i <= synTime; i++) {
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread().getName() + "持有锁,执行同步操作");
}
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 使用tryLock()方法,尝试解除标记时,一定要先判断当前线程是否持有锁
if (isLocked) {
lock.unlock();
}
}
// 执行非同步操作
while (true) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "没持有锁,执行非同步操作");
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m, "线程1").start();
new Thread(t::m, "线程2").start();
}
}
-
设置同步操作耗时4秒,获取锁的等待时间为2秒,输出结果显示线程2在阻塞时间内没能抢到锁,直接执行非阻塞方法。
线程1持有锁,执行同步操作 线程1持有锁,执行同步操作 线程2没持有锁,执行非同步操作 线程1持有锁,执行同步操作 线程2没持有锁,执行非同步操作 线程1持有锁,执行同步操作 线程2没持有锁,执行非同步操作 线程1没持有锁,执行非同步操作 线程2没持有锁,执行非同步操作 线程1没持有锁,执行非同步操作 ...
-
设置同步操作耗时4秒,获取锁的等待时间为5秒,输出结果显示线程2在阻塞时间内成功抢到锁,先执行完同步方法才执行非同步方法。
线程1持有锁,执行同步操作 线程1持有锁,执行同步操作 线程1持有锁,执行同步操作 线程1持有锁,执行同步操作 线程2持有锁,执行同步操作 线程1没持有锁,执行非同步操作 线程2持有锁,执行同步操作 线程1没持有锁,执行非同步操作 线程2持有锁,执行同步操作 线程1没持有锁,执行非同步操作 线程2持有锁,执行同步操作 线程1没持有锁,执行非同步操作 线程2没持有锁,执行非同步操作 ....
2.2 可中断锁lockInterruptibly()
使用lockInterruptibly()
以一种可被中断的方式获取锁。获取不到锁时线程进入阻塞状态,但这种阻塞状态可以被中断。调用被阻塞线程的interrupt()
方法可以中断该线程的阻塞状态,并抛出InterruptedException
异常。
interrupt()方法只能中断线程的阻塞状态.若某线程已经得到锁或根本没去尝试获得锁,则该线程当前没有处于阻塞状态,因此不能被interrupt()方法中断.
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
// 线程1一直占用着lock锁
new Thread(() -> {
lock.lock();
try {
System.out.println("线程1启动");
TimeUnit.SECONDS.sleep(Integer.MAX_VALUE);// 线程一直占用锁
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "线程1").start();
// 线程2抢不到lock锁,若不被中断则一直被阻塞
Thread t2 = new Thread(() -> {
try {
lock.lockInterruptibly(); // 尝试获取锁,若获取不到锁则一直阻塞
System.out.println("线程2启动");
} catch (InterruptedException e) {
System.out.println("线程2阻塞过程中被中断");
} finally {
if (lock.isLocked()) {
try {
lock.unlock(); // 没有锁定进行unlock就会抛出IllegalMonitorStateException异常
} catch (Exception e) {
}
}
}
}, "线程2");
t2.start();
// 4秒后中断线程2
try {
TimeUnit.SECONDS.sleep(4);
} catch (InterruptedException e) {
e.printStackTrace();
}
t2.interrupt();//告诉t2别等了,抛出异常
}
输出:
线程1启动
线程2阻塞过程中被中断
并不是所有处于阻塞状态的线程都可以被interrupt()
方法中断,要看该线程处于具体的哪种阻塞状态。阻塞状态包括普通阻塞、等待队列、锁池队列。
- 普通阻塞: 调用
sleep()
方法的线程处于普通阻塞,调用其interrupt()
方法可以中断其阻塞状态并抛出InterruptedException
异常。 - 等待队列: 调用锁的
wait()
方法将持有当前锁的线程转入等待队列,这种阻塞状态只能由锁对象的notify()
方法唤醒,而不能被线程的interrupt()
方法中断。 - 锁池队列: 尝试获取锁但没能成功抢到锁的线程会进入锁池队列:
- 争抢
synchronized
锁的线程的阻塞状态不能被中断。 - 使用
ReentrantLock的lock()
方法争抢锁的线程的阻塞状态不能被中断。 - 使用
ReentrantLock
的tryLock()
和lockInterruptibly()
方法争抢锁的线程的阻塞状态可以被中断。
- 争抢
关于interrupted()方法的使用,可以查看这篇文章Java中interrupt的使用,总结来说,就是interrupt()方法不能打断线程,但是会给该线程发送一个interrupt信号,让该线程自己决定如何处理该信号,但有一种特殊情况:若该线程正处于阻塞状态,调用其interrupt()方法会抛出InterruptedException.
2.3 公平锁
公平锁:谁等的时间长,谁获得锁
在初始化ReentrantLock
时给其fair
参数传入true
,可以指定该锁为公平锁。默认的synchronized
为非公平锁。
CPU
默认的进程调度是不公平的,也就是说,CPU
不能保证等待时间较长的线程先被执行。但公平锁可以保证等待时间较长的线程先被执行。
public class T implements Runnable {
private static ReentrantLock lock = new ReentrantLock(true);// 指定锁为公平锁
@Override
public void run() {
for (int i = 0; i < 100; i++) {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "持有锁");
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t, "线程1").start();
new Thread(t, "线程2").start();
}
}
程序输出发现两个线程严格交替执行。
四、ThreadLocal 线程局部变量
/*ThreadLocal是使用空间换时间,synchronized是使用时间换空间。
* 比如在Hibernate中的session就存在于ThreadLocal中,避免Synchronized的使用
* 线程局部变量属于每个线程都有自己的,线程间不共享,互不影响*/
public class ThreadLocalTest {
static ThreadLocal<Person> tL = new ThreadLocal<>(); //每个线程的tL互不影响
public static void main(String[] args) {
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(tL.get());
}).start();
new Thread(()-> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
tL.set(new Person());
}).start();
}
static class Person {
String name = "zhangsan";
}
}
第二个线程设置了值,但是第一个线程get得到的是null
,说明线程局部变量是互不影响的。
整理自视频:马士兵老师java多线程高并发编程