Java多线程编程中,经常涉及到多个线程操作同一个对象的问题,为了保证操作结果符合预期,需要保证操作对象线程安全。当操作对象本身就是线程安全的,则可以直接在多线程环境下调用,例如AtomicInteger,ConcurrentHashMap,StringBuffer等;当操作对象不是线程安全时,则需要在操作时进行加锁,保证执行结果符合预期,例如:HashMap,StringBuilder等。
如何理解synchronized
当多线程环境下操作一个非线程安全对象时,可以使用synchronized保证操作线程安全。那么synchronized到底是什么,笔者的理解概括为一句话:synchronized是一个对象锁。定义简单,但这里涉及到两个重要概念,一个是对象(请将类和对象进行区分),一个是锁。用现实世界来解释:synchronized就像一把锁,对象就好比门,锁必须加在门上,不然锁就没有任何价值。所以synchronized的本质是找到合适的“门”进行加锁。
一个简单的例子
上图是一个简单的i++例子,为了便于理解,笔者将i++分解成3步用add()方法表示,在多线程环境下,会出现两个线程同时执行14行代码的可能,导致+1操作在各自的线程中执行后未能及时复制到主存中,使得15行赋值操作被覆盖。
add1()和add2()方法采用synchronized关键字进行修饰,保证同一时间只有一个线程操作add()方法,add1()和add2()在synchronized使用上有所不同,add1()synchronized修饰在方法上,add2()修饰在代码块中。上述提到synchronized是一把对象锁,在add2()方法中,可以明确看出来锁加在this对象上。add1()由于修饰的是对象方法,本质上还是加在this对象上,即add1()和add2()方法等价。
一个容易出错的例子
该例子将锁加在了i对象上,乍一看好像没有问题,但是可能会出现以下情况:线程1首先获取锁,执行add()代码块,线程2由于线程1正在执行,所以被同步等待,线程3可能由于启动较晚,还没有执行到19行代码,即还没有找“这扇门”,当线程1执行完成后,i所指向的这扇门发生了改变(第15行代码),此时线程2由于线程1执行完成所以成功获得执行权,走到15行代码,而线程三也可以执行add()方法代码块,原因是线程三所进入的这扇门已经不再是方法二进入的那扇门,各自线程进入了不同的“门”,最终导致不一致。
为了保证"门"地址不被修改,最规范的写法如下:
创建一扇用final进行修饰的"专用门",防止这扇门的地址被任意的串改,导致锁"失效"。
哪些对象是线程安全,哪些对象不是线程安全?
这个问题没有一个明确的答案,但是笔者的做法是,在使用一个不熟悉的类时,查看该类的注释和源码,简单了解内部实现来确定该类是否可以在多线程环境下使用,这种方式是最为保险的。
每个开发人员对于类的设计思路不同,不能简单的通过类名进行判断,举个例子:hbase java客户端有个类为Connection,该类内部有连接池,可以在多线程下使用,只有当程序中需要操作多个hbase集群时,才需要创建多个Connection实例;java jdbc中的Connection表示对DB的单次链接,多线程环境下需要实现额外的线程池,不能直接共用同一个对象。再拿上述代码举例,ThreadCounter可以完全设计成线程安全,即add()方法采用private修饰,并暴露add1()方法,这样在使用ThreadCounter对象时则不需要再添加额外的锁,因为内部已经使用synchronized控制。当ThreadCounter只有add()方法,则表示ThreadCounter不是线程安全的对象,只适用于单线程环境中,在多线程环境下,需要使用者自己加锁控制。
在设计类时,需要考虑该类的使用场景,不是所有的类需要保证线程安全(加锁的开销较大,绝大部分的类不需要线程安全),但是一些类天生是要在多线程环境下使用的,比如阻塞队列,当队列满或者空时当前线程阻塞,需要被其他线程唤醒,这样的类在设计时就一定需要考虑和保证线程安全问题。
其他注意事项
当synchronized加在静态方法上,则等价于将锁添加在类对象上(类对象也是一个对象,而并非是类,在java中类对象特殊之处在于类对象是一个单例对象[从相同的classloader中获取],这里先不细讲,等介绍到java反射部分后会涉及这部分内容),所以上述add1()和add2()方法是等价的。
总结
1. synchronized是对象锁,真正理解synchronized的本质在于理解对象。
2. 锁的使用难点在于控制最小粒度,保证只有真正需要加锁的地方才加锁,不需要的地方不加锁。这点需要不断的练习和阅读源码才能提高。
3. synchronized和Lock在使用过程中,在都能满足需求的前提下优先使用synchronized,当需要用到如condition,读写锁,tryLock等特性的情况下再考虑使用Lock。