查看原文
其他

java多线程——锁

wier ByteThink 2023-07-10

这是多线程系列第四篇,其他请关注以下:

java 多线程—线程怎么来的?

java多线程-内存模型

java多线程——volatile



如果你看过前面几篇关于线程的文字,会对线程的实现原理了然于胸,有了理论的支持会对实践有更好的指导,那么本篇会偏重于线程的实践,对线程的几种应用做个简要的介绍。


本篇主要内容:

线程安全的分类

线程同步的实现方式

锁优化



4线程安全分类

线程安全并非是一个非真既假的二元世界,如果按照线程安全的“安全程度”来排序的话,java中可以分为以下几类

不可变。对数据类型修饰为final类型的,就可以保证其实不可变的(reference 对象除外,final对象属性不保证,只保证内存地址)。不可变的对象一定是线程安全,比如String类,它是一个典型的不可变对象,当调用它的subString()、replace()和concat()的方法都不会影响它原来的值,只会返回一个新构造的字符串对象。


绝对线程安全。这个定义是极其严格的,一个类要达到,不管运行时环境如何,调用者都不需要任何额外的同步措施。


相对线程安全。这个就是我们通常所讲的线程安全,它保证对对象的单独操作是线程安全的,不需要做额外保证工作,但对于特定顺序的连续调用,可能需要调用端采用额外的同步手段来保证调用的正确性。


线程兼容。这是指对象本身并非线程安全,可以通过调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。我们常用的非线程安全类,都属于这个范畴。


线程对立。这个是指无论调用端是否采用同步措施,都无法在多线程环境中并发使用代码。这种我们应该避免。


上述中可能对绝对安全和相对安全,并不是很好区分,我们采用一个示例来区分:

public class VectorTest {

    private Vector<Integer> vector = new Vector<Integer>();

    public void remove() {

        new Thread() {

            @Override

            public void run() {

                for (int i = 0; i < vector.size(); i++) {

                    vector.remove(i);

                }

            }

        }.start();

    }

    public void print() {

        new Thread() {

            @Override

            public void run() {

                for (int i = 0; i < vector.size(); i++) {

                    System.out.println(vector.get(i));

                }

            }

        }.start();

    }

    public void add(int data) {

        vector.add(data);

    }

    public static void main(String[] args) {

        VectorTest test = new VectorTest();

        for (int j=0;j<100;j++){

            for (int i = 0; i < 10; i++) {

                test.add(i);

            }

            test.remove();

            test.print();

        }

    }

}



上述代码中运行会报错:ArrayIndexOutOfBoundsException的异常,这个异常是在print方法里面出现的,当remove线程删除 一个元素之后,print方法正好执行到vector.get()方法,此时就会出现这个异常。


我们知道vector是线程安全的,它的get()、remove()、size()、add()方法都采用synchronize 进行同步了,但是多线程的情况下,如果不对方法调用端做额外同步的情况下,仍然不是线程安全的。这就是我们说的相对线程安全,它能不保证何时,调用者都不需要任何额外的同步措施。


4线程安全同步的实现方式


互斥同步是常见的一种并发正确性保障手段。同步是指多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用。


java中常见的互斥同步手段就是synchronize 和ReentrantLock。


想必这两种加锁方式对多线程有了解的人都知道。具体用法我们不再探讨。我们聊一聊这两者的不同和具体场景应用。


synchronize在前面的文字中我们也讲过了,它属于重度锁,由于jvm线程是映射于操作系统原生线程,在阻塞或者唤醒线程时候,需要从用户态转换到内核上,这个耗费有时候会耗费时间超过代码执行时间,所以jvm会对一些代码执行短的同步代码采用自旋锁等方式,避免频繁的切入到核心态之中。


synchronize是jvm提供的一种内置锁,被jvm推荐使用,它写出的代码相对比较简单紧凑,只有当内置锁满足不了需求的时候,再来用ReentrantLock。


ReentrantLock

那么ReentrantLock能提供哪些高级功能?我们看个示例;


public void synA() {

    synchronized (lockA) {

        synchronized (lockB) {

            //doSomeThing....

        }

    }

}


public void synB() {

    synchronized (lockB) {

        synchronized (lockA) {

            //doSomeThing....

        }

    }

}


上述通过synchronized的代码,在多个线程分别调用synA和synB的时候容易发生死锁的问题。要想避免,只能在编写的时候强制要求所有的调用顺序一致。而在ReentrantLock可以采用轮询所的方式来避免此种问题。



public void tryLockA() {

    long stopTime = System.currentTimeMillis() + 10000l

    while (true) {

        if (lockA.tryLock()) {

            try {

                if (lockB.tryLock()) {

                    try {

                        // doSomeThing.....

                        return;

                    } finally {

                        lockB.unlock();

                    }

                }

            } finally {

                lockA.unlock();

            }

        }

        if (System.currentTimeMillis() > stopTime) {

            return;

        }

    }

}


上述trylock的方式,如果不能获取到所需要的锁,那么可以采用轮询的方式来获取,从而让程序从新获取控制权,而且会释放已经获得的锁。


另外trylock还提供有定时重载方法,方便你在一定时间内获得锁,如果指定时间内不能给出结果,会零程序结束。


ReentrantLock除了提供可轮询,定时锁以外,还可以提供可中断的锁获取操作,以便获取可取消的操作中使用枷锁。另外还提供了锁获取操作,公平队列以及非块结构的锁。这些功能都极大的丰富了对锁操作的可定制性。


当然了,你如果ReentrantLock的这些高级功能你并用不上,还是推荐采用synchronized。在性能上synchronized在java6以后已经可以和ReentrantLock相平衡了,而且据官方据说,这一方面的性能未来还会加强,因为它属于jvm的内置属性,能执行一些优化,例如对线程封闭锁对象的锁消除优化,以及增加锁的颗粒度来消除锁同步等。这些在ReentrantLock是很难得到实现的。


4锁优化


上述我们也了解过,多线程对资源竞争的时候会令其他没有竞争到的线性进行阻塞等待,而阻塞以及唤醒又要需要内核的调度,这对有限的cpu来说代价过于庞大,于是jvm就在锁优化上花费了大量的精力,以提高执行效率。


我们看看常见的锁优化方式。

自旋锁

在共享数据锁定的状态下,有很多方法都是只会持有很短的一段时间,为了这么一小段时间而让线程挂起和恢复很不值得。那么jvm就让等待锁的线程稍等一下,但不放弃相应的执行时间。以此看等待的线程是否很快释放,如此就减少了线程调度的压力。如果锁被占用时间很短,这个效果就很好,如果时间过长,就白白浪费了循环的资源,而且会带来资源浪费。


自适应自旋锁

自旋锁无法依据锁被占用时间长短来处理,后续就引入了,自适应的自旋锁,自选的时间不再固定了,而是由前一次在同一个锁的自选时间以及拥有的状态来决定的。如此就会变的智能起来。


锁消除

锁消除是指jvm即时编译器在运行时候,对一些代码上要求同步,但被检测到不可能存在共享数据竞争的锁进行消除。锁消除检测主要依据是来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去,就把他们当做栈上数据对待,认为是线程私有的,同步也就无需进行了。


锁粗化

编写代码的时候,总是推荐将同步块作用范围越小越好,如果一系列的操作都是对一个对象反复枷锁和解锁,甚至出现在循环体中,及时没有线程竞争,也会导致不必要的性能损耗。那么对于此种代码,jvm会扩大其锁的颗粒度,对这一部分代码只采用一个同步操作来进行。


轻量级锁

轻量级锁是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥产生的性能消耗。jvm中对象header分为两部分信息,第一部分用于存储对象自身的运行数据,称为”Mark Word”,它是实现轻量级锁的关键。另外一部分用于存储执行方法区对象类型数据的指针。当代码进入同步块的时候,如果此同步对象没有被锁定,则把“Mark world”中指向锁记录的指针,标记为“01”。


如果Mark Word更新成功,线程拥有了该对象的锁,则把执行锁标位的指针标记为”00”,如果更新失败,并且当前对象的Mark word 没有指向当前线程的栈帧,则说明锁对象已经被其他线程抢占了。如果有两条以上 线程争用同一个锁,那轻量级锁就不再奏效了,锁标记为”10“,膨胀为重量级锁。


轻量级锁是基于,绝大部分的锁定,在同步周期内是不存在竞争的,所以以此来减轻互斥产生的性能消耗。当然如果存在锁竞争,除了互斥还会避免使用互斥量的开销,还有额外产生同步修改标记位的操作。


偏向锁

偏向锁是在无竞争情况下把整个同步都消除了,连CAS更新操作也不做了,它会偏向第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步,当另外一个线程尝试获取这个锁的时候,则宣告偏向模式结束。


end

我们是谁?

大码候!

我们要什么?

你的转发关注!

什么时候要?

天天要!


游戏技术研发原创,每周定时更新技术干货。更多好文,扫码关注!




您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存