Java 高并发系列1-开篇

我们都知道在Android开发中, 一个Android程序启动之后会有一个主线程,也就是UI线程, 而网络加载数据, 本地文件长时间读写,图片压缩,等等,很多耗时操作会阻塞UI线程,到时ANR的产生,在Android 3.0 之后便不能在UI线程使用。 由此可见多线程的使用在Android开发中占地位是多么重要。

这个系列 我打算通过一个个的例子来说明多线程的基本概念,多线程的使用, 锁的使用, 并发容器, 线程池的使用,等等。

基本概念

  • 1.线程概念
  • 2.启动一个线程
  • 3.基本的线程同步

1. 线程概念

提到线程时,不得不提到进程。这里有两个问题,

  • 第一 什么是进程, 什么是线程?

我们首先了解一下什么是进程。进程是操作系统结构的基础,是程序在一个数据集合上运行的过程,是系统进行资源分配和调度的基本单位。进程可以被看作程序的实体,同样,它也是线程的容器。例如Mac 监控活动窗口中一个个的任务,这边是操作系统的运行单元,进程。 在Android系统中同样是这样,通过Android Device Monitor 我们可以看到一个进程列表,里面就是Android手机中运行的进程。进程就是程序的实体,是受操作系统管理的基本运行单元。这么说吧, 我们打开的一个又一个App 便是一个又一个应用进程,当然如果某个App做了多进程,该应用便有了两个进程。

先不说线程是什么, 这么说吧,我们使用的QQ浏览器,打开一个网页, 这个网页打开过程,有的加载文本,有的加载图片。这些子任务就是线程,是操作系统调度的最小单
元,也叫作轻量级进程。在一个进程中可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变
量等属性,并且能够访问共享的内存变量,这也就是我们这个系列要研究的对象。

  • 第二 为什么要用多线程?
  1. 充分利用系统资源,提升程序执行效率。就说现在的计算机,动不动就是八核CPU、 16核、32核的 ,如果使用单线程,多浪费资源,这么想可知,只要任务分配的合理,调度合理。 多个人干活肯定比单线程效率高。
  2. 与进程相比,线程创建和切换开销更小,同时多线程在数据共享方面效率非常高
  • 第三 线程的状态

来一张价值连城的线程状态图

Java 高并发下for循环 java怎么实现高并发_锁


简单说一下,Java线程在运行的声明周期中可能会处于6种不同的状态

  1. New 新创建状态。线程被创建,还没有调用 start 方法,在线程运行之前还有一些基础工作要做。
  2. Runnable 可运行状态。一旦调用start方法,线程就处于Runnable状态。一个可运行的线程可能正在
    运行也可能没有运行,这取决于操作系统给线程提供运行的时间。
  3. Blocked 阻塞状态。表示线程被锁阻塞,它暂时不活动。
  4. Waiting 等待状态。线程暂时不活动,并且不运行任何代码,这消耗最少的资源,直到线程调度器
    重新激活它。
  5. Timed waiting 超时等待状态。和等待状态不同的是,它是可以在指定的时间自行返回的
  6. Terminated 终止状态。表示当前线程已经执行完毕。导致线程终止有两种情况:第一种就是run方
    法执行完毕正常退出;第二种就是因为一个没有捕获的异常而终止了run方法,导致线程进入终止状态。

2. 启动一个线程

  • 一种就是 实现Runnable接口
    放入Thread 构造函数中, start 便可启动。 执行的事务便在run方法中执行。
  • 另一种便是实现Callable 接口
    使用方法和Runnable实现的方式一样,
    两者的区别就是,后者有返回值,前者没有返回值。

3. 基本的线程同步

对某个对象加锁。

public class T {
	private int count = 10;
	
	private Object o = new Object();
	
	public void m() {
		synchronized(o) { /// 线程需要执行下边的代码块,就先需要获取o的锁
			count--;
			System.out.println(Thread.currentThread().getName() + " count = " + count);
		}
	}
	
}

如果要执行下边的代码,需要先去申请o这个对象, 堆内存中的这个对象,并不是指o 这个引用, 当然不是指,当o这个引用指向其他对象的时候,锁会变换。
当然如果还没有释放o这个锁,其他线程是没法获取到锁,没有执行权限,所以这也是互斥锁。

如果单单是为了作为一个锁而声明一个对象,就太浪费了。
第二种写法

public class T {
	
	private int count = 10;
	
	public void m() {
		synchronized(this) { // 任何线程执行下边的代码块,需要先获取this 对象
			count--;
			System.out.println(Thread.currentThread().getName() + " count = " + count);
		}
	}
	
}

有人说 synchronized 是锁定的代码块,其实锁定的是对象。

第三种情况

public class T {

	private int count = 10;
	
	public synchronized void m() {  这种加锁写法等同于第二种	synchronized(this) 
		count--;
		System.out.println(Thread.currentThread().getName() + " count = " + count);
	}

}

当synchronized 关键字放在了static 静态方法上时候,

public class T {

	private static int count = 10;
	
	public synchronized static void m() { // 这种加锁方法等同于 synchronized(T.class) 
		count--;
		System.out.println(Thread.currentThread().getName() + " count = " + count);
	}
	
	public static void mm() {
		synchronized(T.class) { // 当然这里不能使用synchronized(T.this)这种写法了, 原因很简单,因为这是静态方法,静态方法调用不需要对象的调用,更不需要使用T.this 这种写法了。
			count --;
		}
	}

}

再看一下这个程序的输出

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();
		}
	}
	
}

这个程序的执行 结果可能是 9,8,7,6,5 当然执行一两次是没有什么问题的, 如果执行的次数很多,问题就会出现。 结果可能是 7,6,7,7,7

这种奇怪的问题稍微解释一下,就是这种情况, 五个线程同时执行没有加锁的一个代码块,执行步骤就是先减,后打印, 当第一个减完,还没来得及打印时候,第二个线程又减了一次,第二个线程还没来得及打印的时候,第三个线程又减了一次, 这时候第一个线程拿到cpu执行时间片,打出的结果就是7, 后续结果就是这么没有规律的打印了出来。

很显然并没有达到我们的预期,这个问题的解决方案就是加锁,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 /*synchronized*/ double getBalance(String name) {
        return this.balance;
    }


    public static void main(String[] args) {
        Account a = new Account();
        new Thread(()->a.set("zhangsan", 100.0)).start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(a.getBalance("zhangsan"));

        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(a.getBalance("zhangsan"));
    }

}

如果在写的过程中进行读操作,这时候就会出现数据的脏读。 当然这时候需要看自己的业务逻辑,
如果允许脏读,对数据的实时性没有要求则可以不做处理,仅对写过程进行加锁。 如果不允许脏读,则对读方法也进行加锁。

public class T {

    synchronized void m1() {
        System.out.println("m1 start");
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        m2();
    }

    synchronized void m2() {
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("m2");
    }
}

一个同步方法可以调用另外一个同步方法,一个线程已经拥有某个对象的锁,再次申请的时候仍然会得到该对象的锁。 会在原来内存中堆内存的锁上+1
也就是说synchronized获得的锁是可重入的。

public class T3 {
    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 T3 {
    @Override
    synchronized void m() {
        System.out.println("child m start");
        super.m();
        System.out.println("child m end");
    }
}

重入锁的第二种情形
这个例子和上个例子是一样的
一个同步方法可以调用另外一个同步方法,一个线程已经拥有某个对象的锁,再次申请的时候仍然会得到该对象的锁.
也就是说synchronized获得的锁是可重入的
这里是继承中有可能发生的情形,子类调用父类的同步方法

如果线程执行在有锁的代码块中抛出异常该如何?

看一条程序

public class T {
	int count = 0;
	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; //此处抛出异常,锁将被释放,要想不被释放,可以在这里进行catch,然后让循环继续
			}
		}
	}
	
	public static void main(String[] args) {
		T t = new T();
		Runnable r = new Runnable() {

			@Override
			public void run() {
				t.m();
			}
			
		};
		new Thread(r, "t1").start();
		
		try {
			TimeUnit.SECONDS.sleep(3);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		new Thread(r, "t2").start();
	}
	
}

在第五秒的时候 t1出现数学算术异常,抛出导致所持有的锁被释放, 同时线程t2获取锁继续执行。

注意: m方法内 如果在数据处理逻辑中执行了一半,抛出异常,锁被释放,而又没有对异常之后的数据进行回滚。 同时其他线程拎起这个原来处理过了一半的数据进行操作的话。 结果必定是不准确的,导致的后果也是灾难性的。

小节结论:

线程执行中抛出异常锁会被释放。 需要添加相关处理逻辑, try-cache

volatile 简单解释意思就是 瞬时的,透明的,临时的, 多个线程可见的。

public class T {

	/*volatile*/ boolean running = true; //对比一下有无volatile的情况下,整个程序运行结果的区别
	
	void m() {
		System.out.println("m start");
		while(running) {
		
			/*
			try {
				TimeUnit.MILLISECONDS.sleep(10);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}*/
		}
		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(1);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		t.running = false;
		
		
	}
	
}

看一下这条程序的运行结果, 一共分两个线程, 一个是t1, 一个是主线程, t1 线程执行while 循环,主线程 修改running 变量。 尝试让t1跳出while 死循环。 结果却并没有让t1跳出死循环。
如果要解释这个现象, 我们需要简单的了解一下java 的内存模型, 简称JMM (java memory model)。

CPU执行区


线程T1 running 线程 T2 running … Tn running


主内存区


running = true ( volatile modify --> notify all thread update )


新的线程执行时,将running 从主内存中拷贝一份到CPU执行区的一个线程缓存区内, 由于CPU一直在执行, 并没有闲暇时间与主内存中的running 进行同步。 所以线程T1便一直处于死循环中。

另一种情况, 当线程while 的死循环中的睡眠代码块 解开之后, CPU便有了与主内存中running 进行了同步, 此时当线程醒来之后 便可以结束了。 ( 具体这是属于什么机制 我还不太懂, 需要进一步学习 |汗)

还有一种情况便是,将running 前加上volatile 关键字,让running 的每一次修改便通知执行线程, 从主内存中读取新的内容,更新缓冲区。

那么volatile 和synchronized 的区别是什么呢?

public class T {
	volatile int count = 0; 
    /* synchronized */	void m() {
		for(int i=0; i<10000; i++) count++;
	}
	
	public static void main(String[] args) {
		T t = new T();
		
		List<Thread> threads = new ArrayList<Thread>();
		
		for(int i=0; i<10; i++) {
			threads.add(new Thread(t::m, "thread-"+i));
		}
		
		threads.forEach((o)->o.start());
		
		threads.forEach((o)->{
			try {
				o.join();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		});
		
		System.out.println(t.count);
	
	}
}

看这一条程序, 虽然count 变量前加上了volatile 关键字,表示该字段的可见性。 但是结果可能是56739, 或者其他 ,但是肯定不会是十万。
由于使用volatile,将会强制所有线程都去堆内存中读取running的值

分析过程:

十条线程同时启动, 同时对主内存中的count 进行了修改操作, 同时从栈中拷贝了一份到自己线程的CPU缓存区内,进行+1 ,完了以后写回到主内存中 101 , 第二个线程也会把加完的结果101 覆盖。 第三条线程可能拿到的是101 ,加完的结果是102 ,第四条可能还是覆盖102, 至此问题便形成。

当然如果把synchronized 注释放开, 结果便是正确的。

当然如果使用系统提供的AtomicXXX 系列类提供的操作方法 也是可以的,当然这也是最优解。

public class T {
	/*volatile*/ //int count = 0;
	
	AtomicInteger count = new AtomicInteger(0); 

	/*synchronized*/ void m() { 
		for (int i = 0; i < 10000; i++)
			//if count.get() < 1000   当然如果这里添加了if判断之后, 这里就不具备了原子性, 很简单,因为判断过程中会有多个线程同时读取到一样的数值,从而造成问题。
		
			// AtomicXXX 这个东西的出现就是为了 代替 count++ 操作。  因为这个操作是原子性的,不可再分的。效率比synchronized高。 
			/具体实现方法应该是使用了最底层的方式。 不太懂希望有懂出来说说。 
			count.incrementAndGet(); //count++
	}

	public static void main(String[] args) {
		T t = new T();

		List<Thread> threads = new ArrayList<Thread>();

		for (int i = 0; i < 10; i++) {
			threads.add(new Thread(t::m, "thread-" + i));
		}

		threads.forEach((o) -> o.start());

		threads.forEach((o) -> {
			try {
				o.join();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		});

		System.out.println(t.count);

	}

}

小节结论:

volatile 只保证了可见性,不保证原子性 效率高 。

synchronized 既保证了可见性,又保证了原子性。 效率低

如果程序可以 请使用 AtomicXXX类进行原子操作代替synchronized。

可以阅读这篇文章进行更深入的理解volatile


再看一条程序

public class T {
	
	int count = 0;

	synchronized void m1() {
		//do sth need not sync
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		//业务逻辑中只有下面这句需要sync,这时不应该给整个方法上锁
		count ++;
		
		//do sth need not sync
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	
	void m2() {
		//do sth need not sync
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		//业务逻辑中只有下面这句需要sync,这时不应该给整个方法上锁
		//采用细粒度的锁,可以使线程争用时间变短,从而提高效率
		synchronized(this) {
			count ++;
		}
		//do sth need not sync
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
}

小节结论:

给只需要上锁的部分进行上锁,以减少线程争用时间,从而提高效率。

再看一条程序

public class T {
	
	Object o = new Object();

	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线程得以执行,如果注释掉这句话,线程2将永远得不到执行机会
		
		t2.start();
		
	}
}

锁定某对象o,如果o的属性发生改变,不影响锁的使用 但是如果o变成另外一个对象,则锁定的对象发生改变

小节小结:

锁定某对象o,对象o是在堆上面的, 并不是栈中对象o的引用。

应该避免将锁定对象的引用变成另外的对象

还应该避免使用字符串常量来作为锁对象,如下 s1 s2 都是字符串变量, m1 m2 锁定的却是同一个对象

public class T {
	
	String s1 = "Hello";
	String s2 = "Hello";

	void m1() {
		synchronized(s1) {
			
		}
	}
	
	void m2() {
		synchronized(s2) {
			
		}
	}

}

好了, 啰里啰嗦,说了一大通,看的云里雾里。 其实我觉得如果能把代码拿出来 敲一下,跑一跑,应该就会明白使用多线程的妙处。 东西比较多,如果有什么不对的,请批评指正。 这篇就先说到这里,下篇我们再见。