文章目录
一、 线程安全问题
线程安全问题使我们平时面试中总避不开会谈论的一个点,通常情况下线程安全问题都是由于非方法内的实例变量引起的。就比如我们举个很简单的例子:
在描述线程的相关Demo中,我喜欢用银行相关的业务场景做举例,大多数和线程相关的知识点都能对应到银行需要用到的业务。就比如今天我会通过一个简单的银行排号系统的实现来解释今天的知识点。
代码实现:
public class BankLineUp {
// 设置一个当天最大服务人次
public static final Integer MAX_NUM = 500;
// 设置一个排号计数器
public static Integer count = 0;
// 创建一个内部类 实现runnable接口
class LineUp implements Runnable {
// 叫号逻辑实现
@Override
public void run() {
// 输出当前叫到的号码
call();
}
}
// 提供构造方法
public LineUp lineUp() {
return new LineUp();
}
public static void main(String[] args) {
BankLineUp bankLineUp = new BankLineUp();
new Thread(bankLineUp.lineUp(), "一号窗口").start();
new Thread(bankLineUp.lineUp(), "二号窗口").start();
new Thread(bankLineUp.lineUp(), "三号窗口").start();
}
// 通过synchronized修饰方法实现同步方法
public void call() {
while (true) {
if (count < MAX_NUM) {
try {
// 通过休眠一秒 模拟系统延时
Thread.sleep(1l);
System.out.println("有请" + ++count + "号顾客到" + Thread.currentThread().getName() + "办理业务!");
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
break;
}
}
}
}
运行结果:
上面的案例我们模拟了因为系统可能存在的延时而导致了叫号系统处理了超过自己限制的数据量,这明显是线程不安全的,那么我们有没有简单的方式可以解决这个小问题呢?
二、synchronized简介
1. 什么是synchronized
synchronized的字面翻译就是同步,Java通过引入synchronized的关键字来保证同一时间只有一个线程可以运行被synchronized保护的代码。
2.什么是同步
同步指的是为了完成某种任务而建立的两个或多个进程,这些进程因为要在某些位置上协调他们的工作次序而瞪大,传递信息所产生的制约关系,因此同步又叫做直接制约关系。在Java线程中的同步主要就体现在如果该资源为同步资源程,为了防止多个线程访问该资源时对数据对象产生破坏,CPU在调度该资源时一次仅允许一个线程访问修改。
3.synchronized的特性
1) 原子性
所谓的原子性指的是同一个或者多个操作,要么全部执行,要么全部不执行
2) 可见性
可见性就是指多个线程访问同一个资源时,该资源状态及变化对其他线程可见。
3) 有序性
有序性指的是CPU对于线程的执行顺序与代码顺序相同。
JAVA允许编译器和处理器对于指令进行重排序,指令重排序并不会影响单线程的执行结果。但是在多线程中,由于存在半初始化线程,指令重排会造成很难排查的线程安全问题。
4)可重入
当一个线程师徒操作一个由其他线程持有锁的临界资源时,将会出于阻塞状态,当一个线程再次请求自己持有对象锁的资源时,无需等待,这种现象叫做可重入锁。
4.synchronized的实现原理(了解即可)
synchronized是在对象头里面存储一个ACC_SYNCHRONIZED标识进行实现的,当JVM进入和退出一个Monitor对象的时候,会判断此时的monitor是否被持有,如果被持有,则它将会处于锁定状态。
在汇编指令中,monitorenter和monitorexit分别代表获得和释放持有的monitor对象锁
三、synchronized的用法
1. 同步方法
在刚刚的例子里,我们发现了共享变量在多线程访问的情况下会出现线程安全问题,之后又引出了synchronized关键字,那么这个关键字该如何用来解决线程安全问题?我们可以看下面的一段代码
package xiao.thread.synchronize;
/**
* @Title: BankLineUp.java
* @Package xiao.thread.synchronize
* @Description: TODO
* @author: 晓
* @date: 2020年11月30日 上午10:15:36
* @version V1.0
* @Copyright: com.cdzg.com
*
*/
public class BankLineUp {
// 设置一个当天最大服务人次
public static final Integer MAX_NUM = 500;
// 设置一个排号计数器
public static Integer count = 0;
// 创建一个内部类 实现runnable接口
class LineUp implements Runnable {
// 叫号逻辑实现
@Override
public void run() {
// 输出当前叫到的号码
call();
}
}
// 提供构造方法
public LineUp lineUp() {
return new LineUp();
}
public static void main(String[] args) {
BankLineUp bankLineUp = new BankLineUp();
new Thread(bankLineUp.lineUp(), "一号窗口").start();
new Thread(bankLineUp.lineUp(), "二号窗口").start();
new Thread(bankLineUp.lineUp(), "三号窗口").start();
}
// 通过synchronized修饰方法实现同步方法
public synchronized void call() {
while (true) {
if (count < MAX_NUM) {
try {
// 通过休眠一秒 模拟系统延时
Thread.sleep(1l);
System.out.println("有请" + ++count + "号顾客到" + Thread.currentThread().getName() + "办理业务!");
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
break;
}
}
}
}
执行结果:
代码变化:
我们仅仅是在call的方法体上加了一个synchronized关键字就实现了线程变量的保护,从而达到线程安全的目的,但是新的问题新出现了:
由于call方法被设置为了同步方法,此时二号线程和三号线程一直在等待一号线程执行完才能拿到cpu的执行权,但是所有的变量此时都被一号线程处理完毕,二号和三号线程并没有实际的参与到代码的运行中,由此可见当我们在将方法添加synchronized后,同步方法的锁力度太大了,已经影响了我们的运行效率,那么此时有没有办法对这个问题进行简单的优化呢?
2. 同步代码块
public class BankLineUp {
// 设置一个当天最大服务人次
public static final Integer MAX_NUM = 500;
// 设置一个排号计数器
public static Integer count = 0;
// 创建一个内部类 实现runnable接口
class LineUp implements Runnable {
// 叫号逻辑实现
@Override
public void run() {
// 输出当前叫到的号码
call();
}
}
// 提供构造方法
public LineUp lineUp() {
return new LineUp();
}
public static void main(String[] args) {
BankLineUp bankLineUp = new BankLineUp();
new Thread(bankLineUp.lineUp(), "一号窗口").start();
new Thread(bankLineUp.lineUp(), "二号窗口").start();
new Thread(bankLineUp.lineUp(), "三号窗口").start();
}
// 通过synchronized修饰方法实现同步方法
public void call() {
while (true) {
synchronized (this) {
if (count < MAX_NUM) {
try {
// 通过休眠一秒 模拟系统延时
Thread.sleep(1l);
System.out.println("有请" + ++count + "号顾客到" + Thread.currentThread().getName() + "办理业务!");
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
break;
}
}
}
}
}
运行结果:
在我们将call方法的同步粒度从整个方法体变成方法中的一个代码块的时候,有效的解决了线程等待方法运行结束才能获得方法锁的问题。那么此时有个小的疑问困扰着我,同步方法和同步方法体使用的是否是统一把锁来控制同步代码的运行???
四、对象锁和类锁
1.对象锁的验证
1.对象锁的探索
为了验证我的疑问,首先需要写一个小的demo
public class ObjectLock {
public static void main(String[] args) {
ObjectLock objectLock = new ObjectLock();
new Thread(() -> {
objectLock.call();
}, "一号线程").start();
new Thread(() -> {
objectLock.speak();
}, "二号线程").start();
}
// 同步方法 通过死循环使同步方法不会退出
public synchronized void call() {
System.out.println(Thread.currentThread().getName() + ":开始运行");
while (true) {
}
}
// 同步代码块
public void speak() {
synchronized (this) {
try {
System.out.println(Thread.currentThread().getName() + ":开始运行");
// 通过sleep延长方法执行时间
Thread.sleep(100_000);
System.out.println(Thread.currentThread().getName() + ":运行结束");
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}
然后打开cmd窗口,输入jps命令,拿到此时执行线程的线程号
然后输入jstack pid 命令查看线程状态:
在这里我们可以看到同步方法call()获取了<0x0000000780676b40>这把锁,而此时使用了同步代码块的方法speak()也在等待获取<0x0000000780676b40>这把锁,因此可以看出在一个对象中,同步方法和同步代码块使用的是同样的锁。在同步代码块中,我们传入了一个参数this,它代表了我们为当前的对象添加了锁,也就是给同步代码块上了对象锁。
类声明后,我们可以 new 出来很多的实例对象。这时候,每个实例在 JVM 中都有自己的引用地址和堆内存空间,这时候,我们就认为这些实例都是独立的个体,很显然,在实例上加的锁和其他的实例就没有关系,互不影响了。
2.对象锁的简介
对象锁也叫方法锁,是针对一个对象实例的,它只在该对象的某个内存位置声明一个标识该对象是否拥有锁,所有它只会锁住当前的对象,而并不会对其他对象实例的锁产生任何影响。为了验证对象锁是否会影响其他对象这个事情,我们将main方法的对象实例进行更换
public static void main(String[] args) {
ObjectLock objectLock = new ObjectLock();
ObjectLock objectLock2 = new ObjectLock();
new Thread(() -> {
objectLock.call();
}, "一号线程").start();
new Thread(() -> {
objectLock2.speak();
}, "二号线程").start();
}
运行结果:
此时二号线程可以无视一号线程持有锁进行的死循环而直接运行,也就证明了我们说的对象锁的影响范围仅为当前对象。或者我们还可以采用下面将参数this更换为其他对象的方式解决:
public void speak() {
synchronized (new String()) {
try {
System.out.println(Thread.currentThread().getName() + ":开始运行");
// 通过sleep延长方法执行时间
Thread.sleep(100_000);
System.out.println(Thread.currentThread().getName() + ":运行结束");
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
这里需要注意的一点:我在这里使用的是new String() 的方式来创建新的对象当做锁,如果此时直接传入的是一个字面量为:abc的字符串,那么此时该同步代码块所持有的将不再是对象锁,而是类锁。
2.类锁的验证
那么什么是类锁?按照惯例,我们还是写个小demo来做验证:
public class ObjectLock {
public static void main(String[] args) {
//分别创建两个对象来调用同步方法 以避免对象锁的干扰
ObjectLock objectLock01 = new ObjectLock();
ObjectLock objectLock02 = new ObjectLock();
new Thread(()->{
objectLock01.test();},"一号线程").start();
new Thread(()->{
objectLock02.test();},"二号线程").start();
}
public void test() {
synchronized (ObjectLock.class) {
System.out.println(Thread.currentThread().getName()+": start!");
while(true) {
}
}
}
}
还是用jstack命令来观察:
此时二号线程和一号线程持有的锁相同,证明此时所有ObjectLock对象的实例都持有该锁。
类锁是加载类上的,而类信息是存在 JVM 方法区的,并且整个 JVM 只有一份,方法区又是所有线程共享的,所以类锁是所有线程共享的。
3.对象锁和类锁的区别
参考文章:https://zhuanlan.zhihu.com/p/98145713
1)对象锁
通常我们使用实例锁的方式有下面三种:
1、 锁住实体里的非静态变量:
非静态变量是实例自身变量,不会与其他实例共享,所以锁住实体内声明的非静态变量可以实现对象锁。锁住同一个变量的方法块共享同一把锁。
2、锁住 this 对象:
this 指的是当前对象实例本身,所以,所有使用 synchronized(this)方式的方法都共享同一把锁。
3、直接锁非静态方法
2)类锁
类锁是所有线程共享的锁,所以同一时刻,只能有一个线程使用加了锁的方法或方法体,不管是不是同一个实例。类锁主要应用在下面的情况中:
1、锁住类中的静态变量
因为静态变量和类信息一样也是存在方法区的并且整个 JVM 只有一份,所以加在静态变量上可以达到类锁的目的。
2、直接在静态方法上加 synchronized
因为静态方法同样也是存在方法区的并且整个 JVM 只有一份,所以加在静态方法上可以达到类锁的目的。
3、锁住 xxx.class
对当前类的 .class 属性加锁,可以实现类锁。