Java Concurrency--Part 1 [Java 并发操作一]
并发问题根本原因是线程执行程序的过程中并不是线性连续的,一个线程执行完才换另一个,可以第一个线程执行一半,第二个线程就开始执行了。之前的blog关于多线程,并发,临界区有过描述。一个可能被多个线程访问而产生并发问题的变量就是临界变量。
Java实现并发的主要关键字是synchronized,这个关键字相当于一把锁,它用来锁住一个对象,如果线程要执行带有synchronized的方法或者代码块,一定要先获得这把锁。所以synchronized锁住的不是一段代码,而是一个对象,只不过要执行这段代码,一定要现获得这个对象的锁。所以锁是在堆空间实现,synchronized在常规方法中是锁定this[堆内存],静态方法中是锁定class[静态区,也是堆内存]
public class T {
private int count = 10;
private Object o = new Object();
public void m() {
synchronized(o) {
count--;
System.out.println(Thread.currentThread().getName()+ " count = " + count);
}
}
}
这里就先new object
出来,用于加锁。但是每次都要消耗内存,单纯用于加锁,过于浪费,所以反正只是用来加锁的对象,可以直接使用this关键字,表示用当前对象加锁。
public class T {
private int count = 10;
public void m() {
synchronized(this) {
count--;
System.out.println(Thread.currentThread().getName()+ " count = " +count);
}
}
}
上面的代码中m()
方法,其实从方法开始就加锁,结束时才释放锁,这种情况下有更简单也更常见的写法,用synchronized关键字直接修饰方法,这两种写法等价。所以也侧面说明synchronized用于对对象加锁而不是代码,而是执行代码的时候锁定当前对象。
public class T {
private int count = 10;
public synchronized void m() {
count--;
System.out.println(Thread.currentThread().getName()+ " count = " +count);
}
}
通过实现runnable接口也可以实现多线程,但是如果下面的run方法没有关键字synchronized,count的值就可能出现重复,这是因为出现了脏读。加上关键字就不会出现这个问题,因为synchronized关键字的代码块相当于原子操作,不会因为多线称就把代码的执行分开。比如,不会出现线程A执行run方法的过程中,线程B也开始执行。
public class T implements Runnable{
private int count = 10;
public synchronized void run() {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
public static void main(String[] args) {
T t = new T();
for(int i = 0; i < 5; ++i) {
new Thread(t, "THREAD" + i).start();
}
}
}
如果一个synchronized修饰的方法m1
和一个一般方法m2
是可以同时执行的,m2
并不需要等m1
释放锁才能执行,因为m2
并不需要检查锁就可以直接执行。这就好比m1
进了拿着1号房间的钥匙开锁进入1号房间,m2
进入了没有锁的2号房间,二者并不冲突。
下面一段代码的getBalance()
没有synchronized会产生脏读。
public class Account {
String name;
double balance;
public synchronized void set(String name, double balance) {
this.name = name;
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.balance = balance;
}
public double getBalance() {
return this.balance;
}
public static void main(String[] args) {
Account a = new Account();
new Thread(()->a.set("Tom", 100.0)).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a.getBalance());
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a.getBalance());
}
}
一个同步方法可以调用另一个同步方法,一个线程已经拥有某个对象的锁,再次申请的时候仍然会得到该对象的锁,也就是说synchronized获得的锁是可以重入的[获得之后还可以再获得一遍]
public class T{
public synchronized void m1() {
System.out.println("m1");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
m2();
}
public void m2() {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m2");
}
public static void main(String[] args) {
T t = new T();
t.m1();
}
}
子类同步方法也可以调用父类的同步方法。下面的代码,子类和父类都是锁定TT
对象,因为默认是synchronized(this)
。
public class T{
public synchronized void m() {
System.out.println("m start");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m end");
}
public static void main(String[] args) {
new TT().m();
}
}
class TT extends T {
@Override
public synchronized void m() {
System.out.println("child m start");
super.m();
System.out.println("child m end");
}
}
如果线程出现异常,则获得的对象锁自动被释放。下面代码中,1/0
计算出异样后,t2
会获得锁运行,如果不希望释放锁,则需要加try catch
捕获异常并进行相应的操作。
public class T{
int count = 0;
public synchronized void m() {
System.out.println(Thread.currentThread().getName() + " start");
while(true) {
count++;
System.out.println(Thread.currentThread().getName() + " count = " + count);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
if(count == 5) {
int i = 1/0;
}
}
}
public static void main(String[] args) {
T t = new T();
Runnable runnable = new Runnable() {
@Override
public void run() {
t.m();
}
};
new Thread(runnable,"t1").start();
try{
TimeUnit.SECONDS.sleep(3);
} catch (Exception e) {
e.printStackTrace();
}
new Thread(runnable, "t2").start();
}
}
volatile关键字主要功能是使变量在线程之间可见,虽然现象是这样但其实背后的原理并不是,参考下面代码:
public class T{
volatile boolean running = true;
public void m() {
System.out.println("m start");
while(running) {
}
System.out.println("m end!");
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m, "t1").start();
try{
TimeUnit.SECONDS.sleep(3);
} catch (Exception e) {
e.printStackTrace();
}
t.running = false;
}
}
原理是JMM[Java Memory Model],每个线程在运行的时候有内存相对独立,也就是说有自己的一部分内存或者缓冲存,在没有volatile
修饰的时候running
是一个常规变量,程序运行的时候主线程的内存中会分配一个running
变量的内存,t1
线程开始执行的时候会把这个变量copy到自己的内存中,这样不用每次需要都去主内存访问,但是此时t2
线程修改了主内存的running
值,但是t1
在持续运行的时候并不知道持续的在读取自己内存中的running
值[如果线程t1
有空闲,有可能去主内存更新值]。加了volatile
并不是让t1
内存中的变量可以被其他线程修改,而是在主内存的对应变量被修改后,通知其他线程,你的内存中的变量已经过期,需要再次访问主内存获取新的值,这样对应线程的变量就会获得更新。
能用volatile,就不用synchronized可以提高线程并发度。volatile算是无锁同步,会强制线程去堆内存中更新值,但是volatile可以保证线程之间变量可见,但是不能保证这个变量的原子性。synchronized可以保证可见性和原子性但是效率差一些,Java1.8之后也进行了部分修改。
如果程序对于变量的需求只是简单的加减,可以使用AtomicInteger获得其他Atomic的类型。这是Java自带的实现原子性的类型,因为通过底层实现,效率比synchronized高很多,在注重性能的场景下,应尽量使用Atomic类型。下面的代码可以达到和使用synchronized相同的结果,但速度快很多。
public class T{
AtomicInteger count = new AtomicInteger(0);
public void m() {
for(int i = 0; i < 10000; ++i) count.incrementAndGet();
}
public static void main(String[] args) {
T t = new T();
List<Thread> threads = new ArrayList<Thread>();
for(int j = 0; j < 10; ++j) {
threads.add(new Thread(t::m, "thread-"+j));
}
threads.forEach((o)->o.start());
threads.forEach((o)->{
try {
o.join();
}catch (Exception e) {
e.printStackTrace();
}
});
System.out.println(t.count);
}
}
之前说过synchronized锁住的不是代码,是对象。所以只要锁的对象不变就可以持续防止其他线程执行同步代码,但是一旦锁的对象被修改,那么新的对象没有被加锁,则其他线程就可以加入执行。下面的代码,t1
执行的时候,t2
也会执行,因为对象o
被修改。
public class T{
Object o = new Object();
public void m() {
synchronized (o) {
while(true) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName());
}
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread t2 = new Thread(t::m,"t2");
t.o = new Object();
t2.start();
}
}
不要使用String类型的对象当作锁的对象,因为String类型,值一样的话,会被当作同一个对象。下面的例子中,看似锁定的是两个对象,其实是一个对象。更为麻烦的是,调用第三方库的时候,如果看不到源码,同时使用String对象加锁,而开发者碰巧也使用String对象,那么就会产生异常。
public class T{
String s1 = "sss";
String s2 = "sss";
void m1() {
synchronized (s1) {
}
}
void m2() {
synchronized (s2) {
}
}
}
wait,notify,notifyAll方法会调用被锁对象的wait,notify和notifAll方法。如果对象调用了wait方法就会使持有该对象的线程把该对象的控制权交出去,然后处于等待状态。如果对象调用了notify方法就会通知某个正在等待这个对象的控制权的线程可以继续运行[无法指定线程]。如果对象调用了notifyAll方法就会通知所有等待这个对象控制权的线程继续运行。
区别在于,wait会释放锁,而notify和notifyAll不会释放锁。所以如果两个或多个线程同时锁定一个对象,需要通过wait和notify来切换使用权。当notify其他线程时,因为notify方法不会释放锁,需要紧跟wait方法,释放该对象的锁。
尽量避免使用wait/notify,比较复杂且效率不高
CountDownLatch方法有时也可以巧妙的实现多线程访问同一对象时线程切换。CountDownLatch相当于设置一个门闩,具体操作是从设定的值开始往下递减直到0为止,门闩开启。Latch await方法不需要锁定任何对象,等待Latch开启[设置的值变成0]。
因为不需要CountDownLatch不需要锁定任何对象,所以效率相对要高很多。
ReentrantLock是手工锁,必须由开发人员显示地加锁和解锁。可以一定程度上替代synchronized。synchronized遇到异常,jvm会自动释放,但是ReentrantLock需要手动释放,所以在try-catch-finally里释放锁。
同时ReentrantLock提供trylock方法,进行尝试加锁,并且根据结果进行后续逻辑。
ReentrantLock还可以指定为公平锁[new ReentrantLock(true)
就是new一个公平锁],首先默认的synchronized都是竞争锁[非公平锁]。比如,一个线程释放锁之后,剩下等待的线程哪个可以获得锁不一定基本是竞争机制[这样做效率高,不需要额外的信息去决定哪个线程获得锁],但是公平锁的机制是等待最久的线程优先获得这个锁
利用ReentrantLock的Condition可以实现类似wait/notify的等待/通知模型[不用使用synchronized]。
所以ReentrantLock更加灵活。
ThreadLocal的用法就是自己线程的变量自己使用,其他线程无法访问。相当于每个变量每个线程都深copy了一份。