浅析java线程安全和加锁机制

随着CPU核心的价格越来越低,多核时代来临。为了充分发挥多核CPU的优势,各大编程语言纷纷对多线程进行了支持,比如Java。特别是在大规模,高并发的web开发中,为了提高系统吞吐率和资源利用率,多线程不和避免,同时我们所使用的各种开源框架基本上都是多线程。但是多线程比单线程要更加复杂,我们必须保证其线程安全性,这就需要用到java中同步和锁。但这些总归只是一些机制,要编写线程安全的代码,其核心在于要对状态访问操作进行管理,我们需要理解对象是否有状态,状态是否可变,可变是否共享,是读操作还是写操作。

对象的状态

线程同步代码是复杂的,并且往往会抑制某些编译器优化,造成额外的性能开销,比如使内存缓存区的数据失效,以及增加共享内存总线的同步流量,最重要它会使并行的流程降级成串行的,这与我们引入多线程的初衷相悖。因此除了对同步机制进行优化外,更好的方法是尽量避免线程同步。那么如何避免呢?我们首先需要理解需要线程同步的那些线程共享的,并且可变的对象。因此我们应该根据需求尽可能的构造无状态的对象,有状态但不可变的对象,可变的但是是线程私有的对象。同时可变的,共享的,但是只有读操作的我们也不需要对它进行额外的同步操作。下面逐一对这些对象进行讲解。

  • 无状态的对象

只有成员方法没有属性的对象。(线程安全)

  • 有状态的但不可变的对象

有成员属性,但属性不可变或者事实不可变的对象。(线程安全)

  • 有状态的,可变的,但是线程私有的对象

有成员属性,并且可变,但这个对象是在一个线程流程内部(可以理解为线程下游的函数内)创建的,此时对它的非静态成员的访问是不需要做额外的线程同步的。(要确保该对象的依赖对象也是线程私有的)

  • 有状态的,可变的,同时又是线程共享的对象

有成员属性,并且可变,同时多个线程都可访问这个对象。(线程不安全,需要额外的同步机制)

影响线程安全的因素

我们总会用到可变的有需要共享的对象,此时要想保证线程安全性,就需要对它进行额外的同步机制。主要就是保证操作的原子性和可见性。

原子性

该原子性与数据库事务中的原子性概念基本是一致的,主要就是描述操作的不可分割性。要么不做,要么全做完。我们来看看下面这段代码:

1
2
3
4
5
6
7
public class ClassA {
private int count;

public int increment() {
return ++count;
}
}

这是笔者随手写的代码,比较简单,主要用于说明原子性这个概念。我们首先来分析一下这段代码,首先count是成员变量,可变;如果该类所对应的某个对象又是在多个线程之间共享的话,那么这段就可能会出问题。因为count++这个操作不是原子的,它分为读-改-写三个操作,如果第一个线程在读改之后还没有写入内存,第二个线程就已经开始读了,那么此时读到的数据就是无效的。当然是否会读到无效数据完全是无法确定的,它可能读对,也可能读错,不确定性正是多线程程序复杂性之一。此时我们就需要额外的同步机制,比如锁,锁这个东西我们之后再说,下面看一下影响线程安全性的另一个因素,可见性。

可见性

可见性是要保证一个线程在对变量修改之后其他线程能立刻可见,要保证可见性,我们需要明白为什么在单线程环境中可见的数据在多线程中就不再可见了。这是由于在多线程环境中,各个线程之间不止会共享内存的数据,它们还会有自己单独的工作内存,比如寄存器。这些工作内存中保存着主内存中数据的副本,线程在修改数据时会先修改自己的工作内存中的数据,然后再同步回主存,其他线程再从主存中更新数据。这样当一个线程在修改自己的副本后没有同步回主存或者不够及时,其他线程就会拿到一个失效的值。

java中内存可见性是通过volatile关键字实现的。volatile写会立即将缓存中的数据刷入主存,volatile读会使当前缓存无效,从主存中重新刷新值。其实volatile不止保证了内存可见性,还防止了指令重排序。至于什么是重排序,volatile又是如何防止指令重排(通过构造内存屏障)的读者可以查阅其他的资料了解。反正挺复杂的,笔者在阅读之后不禁又再次萌生了转行的年头。咱们这行当牛人无数,竞争激烈还得学无止境,太难了。完全没有时间去缅怀一下初恋,或者感受下身边的生活。

java保证线程安全的机制便是通过加锁,java中的锁是比volatile关键字更高一级的线程同步机制,它不止能保证内存可见性,还能保证原子性。一般在保证线程安全性上基本上都是使用锁,volatile关键字的使用场景其实很苛刻,并不常用。下面列举下java中的几种加锁机制。

内置锁

这里指java通过synchronized关键字实现的加锁机制,可分为同步方法和同步代码块。

同步方法示例:

1
2
3
public synchronized void mothodA() {
// 需要同步的代码
}

上面示例的锁便是调用该同步方法的当前对象。同时还有一种方法是静态方法的情况,此时的锁是方法所在类对应的class对象。

同步代码块:

1
2
3
4
5
public void mothodA() {
synchronized(this) {
// 需要同步的代码
}
}

代码块的粒度比同步方法的更小,所以一般推荐使用代码块。synchronized关键字后面的括号中指定的就是锁,this指的是当前对象,同时如果该代码块是放在一个静态方法内的,那么锁就是当前类所对应的Class对象。

显示锁

从JDK5.0开始java引入了Lock框架,与jvm内置的加锁机制(即synchronized)不同,它是对锁的一种抽象。为程序员提供了更多的特性与灵活性,但同时也增加了其他的负担,比如需要手动释放锁。Lock是个接口,它有个实现类ReentrantLock(这其实也是对synchronized的一种模拟,java中锁就是可重入的),下面是一段代码示例:

1
2
3
4
5
6
7
Lock lock = new ReentrantLock();
lock.lock();
try{
// 需要同步的代码--更新对象状态
} finally {
lock.unlock();
}

Lock是对锁的一种抽象,所有synchronized能干的事情它都能干,同时还提供了一些其他的特性,比如公平锁,轮询,定时,中断,无块结构等。此外,实验证明虽然在没有竞争的情况下,Lock的性能比synchronized略低,但在高竞争的情况下Lock的性能比synchronized优秀的多,而在现在多cpu和抢占式调度的情况下,竞争肯定是存在的(虽然比较少)。

看起来Lock似乎处处都优于synchronized,事实上从性能上说可能现在的确如此。但是synchronized有一项Lock比拟不了的优势,那就是简单。synchronized是jvm内置的锁机制,当程序抛出异常时,jvm会自动帮你释放锁,而Lock不行,你必须自己手动在finally释放它。但这很容易遗忘,就会造成死锁这样的重大错误。同时synchronized在调试时很有帮助,因为jvm在线程转储时会包含锁定信息,它能标识死锁和其他异常信息的来源。

此外,从JDK1.6开始,对synchronized加入了很多优化措施,比如自适应自旋锁,锁消除,锁粗化,轻量级锁,偏向锁等,使得synchronized在非竞争的情况下有了很大的性能提升,而且后续可能还会继续优化。synchronized也是官方推荐的同步机制。因此一般只有在需要Lock的一些高级特性时,或者当同步已经成为可伸缩性的瓶颈时,才考虑使用Lock。

结束语

本博客主要是介绍一些线程并发的基础知识,很多地方都没有展开说。而且,囿于笔者自身的知识积累的限制,可能会有些错误,读者不可尽信,应该多找些资料或自己实验验证一下。到时可在评论区提出自己的见解,笔者感激不尽。