Synchronized
为什么要学习Synchronized?
在我们学习多线程的时候,会遇到共享内存两个重要的问题。一个是竞态条件,另一个是内存可见性。解决这两个问题的一种方案是使用Synchronized。在介绍什么是竞态条件,什么是内存可见性之前,我们先讲解一下synchronized的用法和基本原理。
用法
synchronized可以用于修饰类的实例方法、静态方法和代码块
-
synchronized修饰普通同步方法:锁对象为当前实例对象
public synchronized void sayHello(){
System.out.println("Hello World");
}
-
synchronized修饰静态同步方法:锁对象为当前的类Class对象
public static synchronized void sayHello(){
System.out.println("Hello World");
}
-
synchronized修饰同步代码块:锁对象是synchronized后面括号里配置的对象这个对象可以使某个对象,也可以是某个类。
synchronized(this){}
synchronized(xxx.class){}
注意事项:
1.使用synchronized修饰非静态方法或者使用synchronized修饰代码块时制定的为实例对象时,同一个类的不同对象拥有自己的锁,因此不会相互阻塞。
下面代码中synchronized中参数为this,而我创建了2个实例对象,此时锁对象为this。代码结果表明了同一个类的不同对象拥有自己的锁,因此不会相互阻塞。
public class ThreadTest03 extends Thread{
private int number=10;
@Override
public void run() {
synchronized (this){
say();
for(int i=10;i>0;i--){
if(number>0){
System.out.println(Thread.currentThread().getName()+" "+--number);
}
}
}
}
public synchronized void say(){
System.out.println(Thread.currentThread().getName()+"我会说话");
}
public static void main(String[] args) {
ThreadTest03 t1=new ThreadTest03();
ThreadTest03 t2=new ThreadTest03();
t1.start();
System.out.println("t1启动");
t2.start();
System.out.println("t2启动");
}
}
结果展示:2. 使用synchronized修饰类和对象时,由于类对象和实例对象分别拥有自己的监视器锁,因此不会相互阻塞。
类对象只有一个,而实例对象可以有多个。当synchronized参数为类对象时,因为类对象只有一个,当其中一个A线程拿到这把锁时,另一个B线程会被阻塞,因为这个线程拿不到这把锁。只能等A线程释放这把锁。而synchronized参数为实例对象时,下边的代码的实例对象有2个,所以当synchronized的参数为this时,谁调用,这个this就是哪一个实例对象。对象不同,所以他们拥有自己的监视器锁,因此不会产生相互阻塞的情况。
public class ThreadTest03 extends Thread{
private int number=10;
@SneakyThrows
@Override
public void run() {
getThreadClass();
synchronized (ThreadTest03.class){
for(int i=10;i>0;i--){
if(number>0){
System.out.println(Thread.currentThread().getName()+" "+--number);
}
}
}
}
public void getThreadClass() throws InterruptedException {
synchronized (this){
System.out.println(Thread.currentThread().getName()+" "+this.getState());
Thread.sleep(1000);
for(int i=0;i<5;i++){
System.out.println(this.getName());
}
}
}
public static void main(String[] args) {
ThreadTest03 t1=new ThreadTest03();
ThreadTest03 t2=new ThreadTest03();
t1.start();
System.out.println("t1启动");
t2.start();
System.out.println("t2启动");
}
}
结果展示:这里就展示了一部分结果,从线程状态来看,当线程调用synchronized参数为this的代码块时,t1,t2为2个不同实例对象,因为各自有自己的锁,互不阻塞。
3.使用使用synchronized修饰实例对象时,如果一个线程正在访问实例对象的一个synchronized方法时,其它线程不仅不能访问该synchronized方法,该对象的其它synchronized方法也不能访问,因为一个对象只有一个监视器锁对象,但是其它线程可以访问该对象的非synchronized方法。
public class SynLock02 {
public static void main(String[] args) {
Phone1 p1=new Phone1();
Phone1 p2=new Phone1();
new Thread(()->p.SendSms(),"A").start();
new Thread(()->p.call(),"B").start();
new Thread(()->p2.sayHello(),"C").start();
}
}
class Phone1{
// synchronized 锁的是方法的调用者,谁先调用,谁先执行
public synchronized void call(){
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("我会打电话");
}
public synchronized void sendSms(){
System.out.println("我会发短信");
}
// 普通方法不受锁的控制
public void sayHello(){
System.out.println("hello");
}
}
结果展示:可以很清楚的看到没有被synchronized修饰的方法,不受约束,当CPU分给调用此方法的时间片后,即可执行此方法。
4.线程A访问实例对象的非static synchronized方法时,线程B也可以同时访问实例对象的static synchronized方法,因为前者获取的是实例对象的监视器锁,而后者获取的是类对象的监视器锁,两者不存在互斥关系。
public class SynLock03 {
public static void main(String[] args) {
Phone3 p1=new Phone3();
Phone3 p2=new Phone3();
new Thread(()->p1.sendSms(),"A").start();
new Thread(()->p2.call(),"B").start();
}
}
class Phone3{
// 静态 类加载 锁的是class 类模板
public static synchronized void call(){
System.out.println("我会打电话");
}
public synchronized void sendSms(){
try {
// 休眠,来此判断B线程状态是否为RUNNABLE
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("我会发短信");
}
}
结果展示:从结果看来,已证实此说法“.线程A访问实例对象的非static synchronized方法时,线程B也可以同时访问实例对象的static synchronized方法”。
synchronized实现原理
synchronized的实现原理要从Java对象头(32位为例)来讲起,我们先来看一下Java的对象头 Java的对象头有两种方式:一种是普通对象,另一种为数组对象。Java的普通对象组成:
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
Java的数组对象组成:
|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|
Mark Word
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|-------------------------------------------------------|--------------------|
Mark Work:
-
identity_hashcode:每一个对象都会有一个自身的hashcode -
age:分代年龄(关于垃圾回收GC),4位,对象在幸存区复制1次,年龄就会+1,然后对象从新生代到老年代会存在一个关于年龄的阈值,如果达到了这个阈值,这个对象就会放到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。 -
thread:持有偏向锁的线程ID -
lock::2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。 -
biased_lock:对象是否启用了偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
biased_lock | lock | 状态 |
---|---|---|
0 | 01 | 无锁 |
1 | 01 | 偏向锁 |
0 | 00 | 轻量级锁 |
0 | 10 | 重量级锁 |
0 | 11 | GC标记 |
关于这些锁升级的描述,在这里不在叙述,后续也会出一篇关于锁升级的文章。
-
关于state状态描述 -
Normal:正常(无状态) -
Biased:偏向锁 -
Lightweight Locked:轻量级锁 -
Heavyweight Locked:重量级锁 -
Marked for GC:GC
这是对象头(64位) ,与对象头(32位)相似。Mark Word的位长度为JVM的一个Word大小,32位JVM的Mark Word为32位,64位JVM的Mark Word为64位。
|------------------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | Normal |
|------------------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | Biased |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | Lightweight Locked |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | Heavyweight Locked |
|------------------------------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|------------------------------------------------------------------------------|--------------------|
关于Java对象头相关文章可以看一下这篇:https://www.jianshu.com/p/3d38cba67f8b
Monitor 监视器
Monitor被翻译为监视器或管程,如果涉及到操作系统,Monitor通常翻译为管程。每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Workd就被设置指向Monitor对象的指针
Monitor大致结构如下:
-
WaitSet:当有线程调用wait()方法时,线程将会进入到waiting中进行等待唤醒。 -
EntryList:可以看作是一个阻塞等待队列,非公平。 -
Owner:所有者,如果其中一个线程指向了Owner,那么需要等待这个线程执行完他所要做的任务。这时如果有其他线程(同一个对象)进来,那么需要阻塞等待,进入到EntryLis队列当中。
下图解释: 如果有一个Thread-01的线程进来,那么对象头里面的Mark Word会指向这个监视器,当这个线程执行完所需要执行的任务后,就会唤醒阻塞队列中的某一个线程(Thread-04、Thread-05、Thread-06)。
Waiting与Blocked有什么不同之处?
-
Waiting里面的线程是已经拿到过锁的,只不过因为调用了wait()方法,释放了锁。等待其他线程来调用notify()或notifyAll()方法来进行唤醒,但是唤醒后并不意味着直接可以拿到锁,还是需要进入到EntryList阻塞等待队列中进行竞争 -
Blocked里面的线程从来都没有拿到过锁
注意:
Monitor字节码分析 看如下代码:
public class ThreadTest04 {
static final Object o=new Object();
static int number=0;
public static void main(String[] args) {
synchronized (o){
number++;
}
}
}
这里分析的main方法里面的字节码
0 getstatic #2 // object引用(从synchronized开始)
3 dup // 复制最高操作位数堆栈值 (这里就是复制一份然后存储到astore_1临时变量当中)
4 astore_1 // lock引用给到-> slot 1
5 monitorenter // 这里将lock对象 MarkWord置为Monitor指针
6 getstatic #3 // 这里对number变量进行操作
9 iconst_1 // 准备常数 number
10 iadd // 进行++操作
11 putstatic #3 // 赋值给number
14 aload_1 // lock引用
15 monitorexit // 将lock对象Mark Word重置,唤醒EntryList
16 goto 24 (+8) // 如果没有异常,直接return结束
19 astore_2 // slot 2 异常 exception对象
20 aload_1 // lock的引用
21 monitorexi t // 将lock对象Mark Word重置,唤醒EntryList
22 aload_2 // slot 2 (e) exception对象
23 athrow // 抛出异常 throw e
24 return
异常表:这里的异常会有一个范围从6到16、从19到22 ,如果出现异常就跳转到19行。好啦,Java对象以及Monitor工作原理,想必大家应该有所收获。
什么是竞态条件?
竞态条件指的是当多个线程访问和操作同一个对象时,最终结果与执行顺序有关,可能正确也可能不正确。看下面代码。
public class ThreadTest02 extends Thread {
private static int number=0;
@Override
public void run() {
for(int i=0;i<1000;i++){
number++;
}
}
public static void main(String[] args) throws InterruptedException {
int num=1000;
Thread[] t=new Thread[num];
for (int i = 0; i <num ; i++) {
t[i]=new ThreadTest02();
t[i].start();
}
for (int i=0;i<num;i++){
t[i].join();
}
System.out.println(number);
}
}
运行结果:这段代码很容易理解,有一个共享静态变量number,初始值为0,在main方法中创建了1000个线程,每个线程对counter循环加1000次,main线程等待所有线程结束后输出counter的值。期望的结果是100万,但实际执行,每次输出的结果都不一样,大多数情况下是99万吧。为什么会这样?这是因为number++这个操作不是原子操作。
-
首先去number的当前值 -
在当前值的基础上加1 -
将新值重新复制给number
因为竞态条件的产生可能会出现某两个线程同时执行第一步,取到了相同的number值,比如都取到了50,第一个线程执行完后number变为51,而第二个线程执行完后还是51,最终的结果就与期望不符。此时如果要解决这个问题,有多种方案,这里就是用synchronized解决。解决方案:
public class ThreadTest02 extends Thread {
private static int number=0;
@Override
public void run() {
// 这里使用的synchronized的代码块
synchronized (""){
for(int i=0;i<1000;i++){
number++;
}
}
}
public static void main(String[] args) throws InterruptedException {
int num=1000;
Thread[] t=new Thread[num];
for (int i = 0; i <num ; i++) {
t[i]=new ThreadTest02();
t[i].start();
}
for (int i=0;i<num;i++){
t[i].join(); // 加入线程,谁调用让谁加入
}
System.out.println(number);
}
}
上述代码中,synchronized参数中我使用的锁是同一个对象,我没有去使用this,因为在循环当中,我是new了1000个对象,所以去调用start的方法的是不同的对象,所以在这里使用this起不到任何用处。如果还不是很懂,那么我在举一个生活当中的案例。
卖票案例
public class TicketTest {
public static void main(String[] args){
Ticket t = new Ticket();
for(int i=0;i<4;i++){ // 模拟4家卖票机构
new Thread(()-> {
try {
t.sale();
} catch (InterruptedException e) {
e.printStackTrace();
}
},i+"").start();
}
}
}
class Ticket{
// 假如一共100张票
private static int ticketNumber=100;
public void sale() throws InterruptedException {
while(true){
if(ticketNumber>0) {
Thread.sleep(100); // 这里停顿,是为了模拟出票的时间
System.out.println("线程"+Thread.currentThread().getName()+"卖出了1张票还剩"+--ticketNumber+"张");
}else{
break;
}
}
}
}
运行结果:代码里面它们有一个共享的变量ticketNumber,初始化的值为100,main方法中创建了4个线程,每个线程启动后,都会对ticketNumber不停的-1,直到为0停止。
但是当运行出来后,结果与我们期望的结果不一致。为什么呢?
因为竞态条件的产生,可能会有多个线程同时执行第一步,取到了相同的ticketNumber值,比如第一个线程取到了100减去了1,还剩99张票。第二线程还是从100的基础上减1,没有在第一个线程执行后的结果后减1。导致出现了同一张票重复销售的情况。解决这种问题,可以尝试加锁,一种方案是使用synchronized。
解决方案 (使用synchronized代码块)
public class TicketTest {
public static void main(String[] args){
Ticket t = new Ticket();
for(int i=0;i<4;i++){ // 模拟4家卖票机构
new Thread(()-> {
try {
t.sale();
} catch (InterruptedException e) {
e.printStackTrace();
}
},i+"").start();
}
}
}
class Ticket{
// 假如一共100张票
private static int ticketNumber=100;
public void sale() throws InterruptedException {
while(true){
synchronized (this){
if(ticketNumber==0){
return; // 当其中一个线程获取锁后,先检查票数是否为0,如果为0直接return
}
if(ticketNumber>0) {
Thread.sleep(100); // 这里停顿,是为了模拟出票的时间
System.out.println("线程"+Thread.currentThread().getName()+"卖出了1张票还剩"+--ticketNumber+"张");
}else{
break;
}
}
if(ticketNumber==0){
System.out.println("车票已售空!!!");
}
}
}
}
结果展示:这里没有使用synchronized修饰sale方法,因为不适合模拟抢票案例。目的是为了让多个线程同时去卖票。如果在方法中使用synchronized,那么其中一个线程会一直占有锁,其他线程只能被阻塞。大家可自行去尝试。
什么是内存可见性?
内存可见性就是多个线程共享访问和操作相同的变量,但一个线程对一个共享变量的修改,另一个线程并不能马上被看到,甚至永远也看不到。
public class ThreadTest01 {
private static boolean flag=false;
public static void main(String[] args) throws InterruptedException {
Thread01 t=new Thread01();
t.start();
Thread.sleep(1000); // 主线程休息1秒
flag=true;
System.out.println("主线程修改flag值,主线程结束");
}
static class Thread01 extends Thread{
@Override
public void run() {
while(!flag){
/* try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}*/
//System.out.println("1");
}
System.out.println("子线程结束");
}
}
}
当我们去运行此代码时,你会发现主线程运行完毕,并修改了flag的值,子线程并没有结束。为什么会这样呢?当主线程开始运行的时候,flag为false,并创建了一个子线程,这个子线程会将flag复制到运行的内存中,子线程在运行时,flag一直为false。进入while后,条件一直为true。主线程休息一会后,将flag变为了true,但是影响不了子线程的运行内存中的flag值,因此flag在子线程中一直为false。所以会陷入死循环。
在计算机的系统中,除了内存。数据还会被缓存在CPU的寄存器以及各种缓存中,当访问一个变量时,可能直接从寄存器或CPU缓存中获取,而不一定到内存中去取,当修改一个变量时,也可能是先写到缓存中,稍后才会同步更新到内存中。在单线程的程序中,这一般不是问题。但是在多线程的程序中,尤其是在有很多CPU的情况下,这就是严重的问题。一个线程对内存的修改,另一个线程看不到,一是修改没有及时同步到内存,二是另一个线程根本就没从内存读。
如何解决上述问题:
-
synchronized或显示锁同步 -
volatile关键字
synchronized解决上述问题
public class ThreadTest01 {
private static boolean flag=false;
public static void main(String[] args) throws InterruptedException {
Thread01 t=new Thread01();
t.start();
Thread.sleep(1000); // 主线程休息1秒
flag=true;
System.out.println("主线程修改flag值,主线程结束");
}
static class Thread01 extends Thread{
@Override
public void run() {
while(!flag){
synchronized (this){
}
/* try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}*/
//System.out.println("1");
}
System.out.println("子线程结束");
}
}
}
其实还有两种方法,在while循环中除了使用了synchronized的代码块,还有一个定时休眠以及一个打印语句(这两种方法我注释了)。后两种方法也能够结束循环。具体原因:
先看一下多线程下的内存模型
对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。
println()方法为什么会结束循环?
我们来看一下println()方法的源码
public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}
println方法中被synchronized加锁了。他会做出以下操作:
-
获取同步锁 -
清空内存 -
从主内存中拷贝新的对象副本到工作线程中 -
继续执行代码,刷新主内存的数据 -
释放同步锁
在清空内存刷新内存的过程中,子线程有这么一个操作:获取锁到释放锁。子线程的Flag就变成了true(从主内存拷贝对象副本到线程工作内存中),所以就跳出了循环。指令重排序的情况也就不会出现了,这也是volatile关键字的两种特性之一,所以使用volatile关键字修饰flag变量也能解决此问题。
sleep()方法为什么也能结束循环?
子线程调用sleep()时,线程虽然休眠了,但是对象的机锁没有被释放。当锁释放后,又会从从主内存拷贝对象副本到线程工作内存中。
不过,如果只是为了保证内存可见性,使用synchronize的成本有点高,有一个轻量级的方式,那就是使用volatile关键字去修饰这个flag变量。具体volatile是做什么的?这里就不解释了。因为此文章是针对于synchronized,后期我会出一篇关于volatile关键字的文章。
死锁问题
使用synchronized,要注意死锁。所谓的死锁就是类似这种线程,比如有a和b两个线程。a持有锁对象lockA,b持有锁对象lockB,b在等待锁lockA时,a线程和b线程都陷入了相互等待,最后谁都执行不下去。
这种情况,应该尽量避免在持有在持有一个锁的同时去申请另一个锁,如果确实需要多个锁,所有代码都应该按照相同的顺序去申请锁。可以约定都先申请lockA,在申请lockB。
public class ThreadTest05 {
private static Object lockA=new Object();
private static Object lockB=new Object();
private static void threadA(){
new Thread(()->{
synchronized (lockA){
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB){
}
}
}).start();
}
private static void threadB(){
new Thread(()->{
synchronized (lockB){
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockA){
}
}
}).start();
}
public static void main(String[] args) {
threadA();
threadB();
}
}
解决:
-
应该尽量避免在持有一个锁的同时去申请另一个锁,如果确实需要多个锁,所有代码都应该按照相同的顺序去申请锁。 -
使用显式锁接口Lock,它支持尝试获取锁(tryLock)和带时间限制的获取锁方法,使用这些方法可以在获取不到锁的时候释放已经持有的锁,然后再次尝试获取锁或干脆放弃,以避免死锁
// 可以都先约定好,都先申请了lockA,在去申请lockB
public class ThreadTest05 {
private static Object lockA=new Object();
private static Object lockB=new Object();
private static void threadA(){
new Thread(()->{
synchronized (lockA){
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB){
}
}
}).start();
}
private static void threadB(){
new Thread(()->{
synchronized (lockA){
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB){
}
}
}).start();
}
public static void main(String[] args) {
threadA();
threadB();
}
}
总结
-
synchronized可以保证原子性操作 -
synchronized可以保证内存可见性,在释放锁时,所有写入都会写回内存,而获得锁后,都会从内存中读取最新的数据。如果只是简单操作变量的话,可以用volatile修饰该变量,替代synchronized来减少成本。 -
使用synchronized要注意死锁问题 -
可重入性:每个锁关联一个线程持有者和一个计数器。当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁
原文始发于微信公众号(阿黄学编程):简单学Synchronized
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/35600.html