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浏览器,打开一个网页, 这个网页打开过程,有的加载文本,有的加载图片。这些子任务就是线程,是操作系统调度的最小单
元,也叫作轻量级进程。在一个进程中可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变
量等属性,并且能够访问共享的内存变量,这也就是我们这个系列要研究的对象。
- 第二 为什么要用多线程?
- 充分利用系统资源,提升程序执行效率。就说现在的计算机,动不动就是八核CPU、 16核、32核的 ,如果使用单线程,多浪费资源,这么想可知,只要任务分配的合理,调度合理。 多个人干活肯定比单线程效率高。
- 与进程相比,线程创建和切换开销更小,同时多线程在数据共享方面效率非常高
- 第三 线程的状态
来一张价值连城的线程状态图
简单说一下,Java线程在运行的声明周期中可能会处于6种不同的状态
- New 新创建状态。线程被创建,还没有调用 start 方法,在线程运行之前还有一些基础工作要做。
- Runnable 可运行状态。一旦调用start方法,线程就处于Runnable状态。一个可运行的线程可能正在
运行也可能没有运行,这取决于操作系统给线程提供运行的时间。 - Blocked 阻塞状态。表示线程被锁阻塞,它暂时不活动。
- Waiting 等待状态。线程暂时不活动,并且不运行任何代码,这消耗最少的资源,直到线程调度器
重新激活它。 - Timed waiting 超时等待状态。和等待状态不同的是,它是可以在指定的时间自行返回的
- 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) {
}
}
}
好了, 啰里啰嗦,说了一大通,看的云里雾里。 其实我觉得如果能把代码拿出来 敲一下,跑一跑,应该就会明白使用多线程的妙处。 东西比较多,如果有什么不对的,请批评指正。 这篇就先说到这里,下篇我们再见。