Java线程安全
Java中的线程安全
一个比较严格的线程安全定义:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的
Java语言中的各种操作的共享数据可以分为以下五类:不可变,绝对线程安全,相对线程安全,线程兼容和线程对立。
不可变
不可变的对象,他的线程一定是安全的。
绝对线程安全
绝对线程安全需要满足上面提到的定义。而Java API中提到的线程安全的类,大多都是相对线程安全。比如说Vector是一个线程安全的容器,因为他的add(), get(), size()的方法都是用synchronized修饰的。尽管这样,并不意味着它永远不需要同步手段。
比如以下代码:
1 | private static Vector<Integer> vector = new Vector<Integer>(); |
这段代码运行之后就会报错,原因是如果一个线程在错误的时间删除一个元素,那么其他元素在获取对应下标时就会报错。虽然它的get和remove都是原子操作,但是原子操作组合原子操作并不一定是原子操作。
当调用vector.size()获取大小时,它确实是原子性的。但是后续循环中,vector中的元素会被删除,导致get操作获取错误的下标。
所以说在操作这些容器时,还是需要手动加锁。
相对线程安全
相对线程安全就是我们通常意义上所说的安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的操作,但是对于特定顺序的调用,就需要额外操作的同步手段来保证。
线程兼容
线程兼容是指对象本身不是线程安全的,但是可以通过在调用端使用一些同步手段来保证线程安全。比如HashMap, ArrayList等。
线程对立
线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。
线程安全的实现方法
1、互斥同步
互斥同步在多个线程访问共享数据时,保证共享数据在同一时刻只被一条线程使用。
Java中最基本的互斥同步手段就是synchronized关键字,这是一种块结构的同步语法。synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。
在执行monitorenter时,会先尝试获取锁,如果对象没有被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数加一,而在执行monitorexit时就会把锁的计数器减一。计数器为0,释放锁。如果获取锁失败,就会被阻塞,直到请求锁定的对象上面的锁被释放。
这也就意味着,synchronized修饰的变量,一个线程获取锁后,可以在上面加很多把锁,也不会造成死锁,而且不可被中断。
除了synchronized关键字以外,jdk5以后,提供了JUC包的Lock接口,可以让用于以非块结构实现互斥同步。
ReentrantLock是Lock接口最常见的实现,它也是可重入锁,但是与synchronized有一定的区别,主要是以下三点:
1、等待可中断:当持有锁的线程长时间没有释放锁时,等待这把锁的线程可以选择放弃等待,而synchronized修饰的却不可以(获取锁失败会被阻塞)。
2、公平锁:多个线程等待锁,会按照申请锁的顺序依次获得。ReentrantLock默认情况下是非公平锁,一旦开启公平锁,会影响性能。
3、绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。
1 | Lock lock = new ReentrantLock(); |
这段代码就相当于把lock绑定了三个Condition,可以通过c1或者c2,在不同的情况下是一个线程休眠或者唤醒一个线程。而synchronized想要关联多个条件,则需要添加多个锁。
2、非阻塞同步
互斥同步不可避免的面临线程唤醒时带来的性能开销,这种也称为阻塞同步。互斥同步可以理解为一种悲观锁,即不加锁就一定会出问题,所以无论是否出现竞争,都会加锁,这会导致用户态切换到核心态的转换、维护锁计数器和检查是否有阻塞的线程等待唤醒。
随着硬件发展,有了另一种方案,基于冲突检测的乐观并发策略,即不管风险,直接进行操作,如果没有其他线程竞争,则执行成功,否则就一直尝试,直到没有竞争。这种方案不需要把线程阻塞挂起,因此也成为非阻塞同步。
JDK5之后,java类库开始使用CAS操作。CAS指令需要有三个操作数,分别是内存位置,旧的预期值以及准备设置的新值。当该指令执行时,仅当内存位置的值符合预期值时,才会用新的值去更新内存位置的值。
但是CAS检查并不一定能保证该值没有被修改过,例如一个值本来是1,在执行CAS操作时,它的值被改为了2,然后又被改为了1,虽然检查时还是1,但是值已经被修改了。
JUC包对此做了修改,控制变量的版本来保证没有被修改过。
参考
《深入理解Java虚拟机》