synchronized关键字

多线程编程中,最让人头疼的问题莫过于线程安全,如果对存在线程安全问题的代码不加以处理,可能会带来严重的后果,例如用两个线程对同一个变量进行增加操作

class Counter {
    //这个 变量 是两个线程要去自增的变量
    public int count;
    public void  increase() {
        count++;
    }
}

public class Demo15 {
    private static Counter counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for(int i = 0; i < 50000; ++i) {
                counter.increase();
            }
        });
        t1.start();

        Thread t2 = new Thread(()->{
            for(int i = 0; i < 50000; ++i) {
                counter.increase();
            }
        });
        t2.start();

        //等待t1和t2执行完,再打印count的结果
        t1.join();
        t2.join();

        //在main中打印一下两个线程自增完成后,得到的count结果
        System.out.println(counter.count);

        //如果不加锁,极端情况
        //所有的操作都是串行的,最终结果就是10w(可能出现,极小概率事件)
        //所有操作都是交错的(并行),最终结果就是5w(可能出现,极小概率事件)
    }
}

在这里插入图片描述

预期结果为10w,但实际结果却为67627,这就是线程安全问题导致的。

产生线程不安全的原因有很多:

  1. 线程是抢占式执行的,线程之间的调度充满随机性(线程不安全的万恶之源,但是我们无可奈何)
  2. 多个线程对同一个变量进行修改操作(如果多个线程针对不同的变量进行修改,没事。如果多个线程针对同一个变量读,也没事),上诉代码线程不安全就是这个原因导致的
  3. 针对变量的操作不是原子的(针对有些操作,比如读取变量的值,只是对应的一条机器指令,此时这样的操作本身就可以视为原子的。通过加锁操作,也可以把好几条指令打包成原子的)
  4. 内存可见性也会影响到线程安全。例如针对同一个变量,线程A进行循环读取,但循环内部并不修改,此时编译器就会优化,把这个变量从内存保存到寄存器中,每次都读取寄存器中的内容(这样做是为了提高效率,因为寄存器的读写效率高于内存),此时线程B修改了这个变量(从内存中读到CPU上,修改完毕后,再写回内存),但不会影响线程A,因为线程A并没有从内存中读取这个变量。在Java中,内存可见性用关键字volatile保证,但它不保证原子性
  5. 指令重排序也会影响到线程安全,不了解的可以看我之前写的博客指令重排问题。大部分代码,彼此的顺序,谁在前,谁在后,无所谓,些代码却依赖前后关系,编译器就会智能的调整代码的前后顺序,从而提高程序的效率,但是应该保证逻辑不变的情况下,再去调整顺序。如果代码是单线程的程序,编译器的判定一般都是很准的,但是如果代码是多线程,编译器也可能存在误判。 volatile也能防止指令重排序

上述介绍的这5种情况,都是产生线程不安全的原因

对此我们应该对increase()方法加锁或者count这个变量加锁,在C++中需要创建mutex变量,再加锁,然后再解锁,个人觉得有点麻烦(C++加锁的方式有很多)。在Java中,加锁的方式也有很多,最简单,最常用的方式就是在increase()方法最前面加上synchronized关键字,将整个方法锁住,这样就解决了线程安全问题

class Counter {
    //这个 变量 是两个线程要去自增的变量
    public int count;
    synchronized public void  increase() {
        count++;
    }
}

在这里插入图片描述

synchronized 会自动加锁,本质是修改了Object对象中的"对象头"里面的一个标记
synchronized是个可重入锁,可重入锁内部会记录当前的锁被哪个线程占用,同时也会记录一个"加锁次数(引用计数)"。当锁的计数减到0后,就解锁,可重入锁的意义就是降低了使用成本,提高了开发效率,但是也带来了更大的开销(维护锁属于哪个线程,并增加了计数,降低了运行效率)

synchronized 的使用方法
1.直接修饰普通的方法
使用synchronized的时候,本质就是针对某个"对象"进行加锁,此时锁对象就是this

在这里插入图片描述

2.直接修饰代码块
需要显示指定哪个对象需要加锁(Java中的任何对象都可以作为锁对象)

在这里插入图片描述

3.修饰静态方法(更严谨的叫法应该是"类方法")
相当于针对当前类的类对象加锁

在这里插入图片描述

synchronized

  1. 既是一个乐观锁,也是一个悲观锁(根据锁竞争的激烈程度,自适应)
  2. 是一个普通的互斥锁
  3. 既是一个轻量级锁,也是一个重量级锁(根据锁竞争的激烈程度,自适应)
  4. 轻量级锁的部分基于自旋锁实现,重量级的部分基于挂起等待锁实现
  5. 非公平锁
  6. 可重入锁

synchronized几个典型的优化手段(只考虑JDK1.8)

1.锁膨胀/所升级

体现synchronized能够"自适应"这样的能力
代码还能执行到synchronized部分,此时处于无锁状态
当首个线程执行到了synchronized部分,此时就会进入偏向锁状态,偏向锁只是做了一个标记,并没有真的加锁,这样带来的好处就是后续如果没有线程竞争,就避免了加锁,解锁带来的开销
如果此时又有其他线程执行到了synchronized部分,产生锁竞争,此时进入**轻量级锁(自旋锁)状态
如果竞争进一步加剧,就会进入
重量级锁(互斥锁)**状态

无锁——>偏向锁——>轻量级锁(自旋锁)——>重量级锁(互斥锁)

2.锁粗化/细化

此处的粗细是指"锁的粒度"
"锁的粒度"是指加锁的代码涉及到的范围
加锁的代码范围越大,认为锁的粒度越粗
加锁的代码范围越小,认为锁的粒度越细

到底锁的粒度是粗好,还是细好?各有各的好
如果锁的粒度比较细,多个线程之间的并发性就更高
如果锁的粒度比较粗,加锁解锁的开销就更小

Java编译器就会有一个优化,会自动判定(一般来说编译器优化后,效率会变高,但也有意外情况)
如果两次加锁之间的间隔较大(中间隔的代码多),会细化(一般不会进行这种优化)
如果两次加锁之间的间隔较小(中间隔的代码少),会粗化(很可能触发这个优化)


3.锁消除

有些代码,明明不用加锁,结果你给上锁了,编译器就会发现这个加锁操作好像没什么必要,就直接把锁给去掉了

例如给单线程进行加锁,这个时候编译器就会进行锁消除,
单线程中使用到了StringBuffer,Vector等,它们都是在标准库中进行的加锁操作,实际使用的时候可能存在锁消除

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值