null
用生活中的场景来比喻的话呢,就是假设你住在一个小区,这个小区就是一个操作系统,你家就是一个进程,你家的柴米油盐是不跟其他户人家共享的,为什么?因为你们互相之间没关系。这个柴米油盐就是资源。 线程就是你们这个家的人,你们互相之间同时运行,可以同时干自己的事情。
线程创建的方式主要包括:
/** * 使用 Thread 类来定义工作 */ static void thread() { Thread thread = new Thread() { @Override public void run() { System.out.println("Thread started!"); } }; thread.start(); 复制代码
实现Runnable接口创建线程
/** * 使用 Runnable 类来定义工作 */ static void runnable() { Runnable runnable = new Runnable() { @Override public void run() { System.out.println("Thread with Runnable started!"); } }; Thread thread = new Thread(runnable); thread.start(); } 复制代码
Callable是有返回值的Runnable。
static void callable() { Callable<String> callable = new Callable<String>() { @Override public String call() { try { Thread.sleep(1500); } catch (InterruptedException e) { e.printStackTrace(); } return "Done!"; } }; ExecutorService executor = Executors.newCachedThreadPool(); Future<String> future = executor.submit(callable); try { String result = future.get(); //get是一个阻塞方法,虽然你换了个线程,但是你取数据的时候还是会卡住 System.out.println("result: " + result); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } } 复制代码
feature.get()是一个阻塞方法,那么有没有办法不卡住线程呢? 答案是有的,那就是循环去查:
Future<String> future = executor.submit(callable); try { while(!future.isDone){ //检查是否已经完成,如果否,那么可以让主线程去做其他操作,不会被阻塞 } String result = future.get(); //get是一个阻塞方法,虽然你换了个线程,但是你取数据的时候还是会卡住 System.out.println("result: " + result); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } 复制代码
JDK 1.5后引入的Executor框架的最大优点是把任务的提交和执行解耦。通过Executors 的工具类可以创建以下类型的线程池:
下面介绍常用的两种线程池: (1)FixThreadPool : 创建固定大小的线程池。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。 适用于集中处理多个任务。 举个例子:如果现在我需要优先处理一下图片,但是处理完就释放掉这些线程,那么代码可以这么写:
ExecutorService imageProcessor = Executor.newFixedThreadPool(); //我需要你马上给我很多个线程,然后一旦用完我就不要了 List<Image> images; //图片集合 for(Image image : images){ //处理图片 improcessor.excutor(iamgeRunnable,image); } //等图片处理完成后终止线程 imageProcessor.shutdown(); 复制代码
(2)cacheThreadPool 缓存线程池 当提交任务速度高于线程池中任务处理速度时,缓存线程池会不断的创建线程 适用于提交短期的异步小程序,以及负载较轻的服务器
static void executor() { Runnable runnable = new Runnable() { @Override public void run() { System.out.println("Thread with Runnable started!"); } }; Executor executor = Executors.newCachedThreadPool(); executor.execute(runnable); executor.execute(runnable); executor.execute(runnable); } 复制代码
Executor接口里面有两个重要的方法,一个是shutdown,一个是shutdownNow。 他们两个的区别是:shutdown不再允许扔新的runnable进来。shutdownNow不只是新的不允许,就算是正在执行的任务也不允许再继续执行。
:raising_hand:♀️辟谣: 网上有一个说法是:创建的线程池大小,取决于CPU的核数。 比如,你CPU有8个核,就创建8个线程,每个线程分配给你一个核,这样想想很有道理。但是其实是没道理的!你有8个核,你就占了所有的核了吗?不是这样的。不过,你的线程数跟你的CPU挂钩是有道理,它可以让你的软件在不同机器上表现相对一致。 所以,你的线程数跟你的CPU挂钩有道理,但是线程数=CPU核数就没道理了。大家记住了吧。
线程同步主要包括以下内容:
在说明synchronized为什么能保证线程安全之前,我们先简单过一下JVM内存模型。
Java的内存模型有以下特点:
(1)把工作内存1中更新过的共享变量刷新到主内存中 (2)将主内存中最新的共享变量的值更新到工作内存2中
如果一个 线程对共享变量的修改,能够被其他线程看到 ,那么就说此时是可见的。
原子性也就是不可再分,不能再分为分步操作。 比如:
int a =1 ;//是原子操作 a+= 1;//不是原子操作 a+=1 实际分为三步: 1. 取出a = 1 2. 计算a + 1 3. 将计算结果写入内存 复制代码
在Java中, 代码书写的顺序并不等于代码执行的顺序 。有时候,编译器或者处理器为了能提高程序性能,会对代码指令进行重排序。
重排序不会给单线程带来内存可见性问题,但是在进行多线程编程时,重排序可能会造成内存可见性问题。
举个例子:
int num1= 1; //第一行代码 int num2 = 2; //第二行代码 int sum = num1 + num2; //第三行代码 //在进行重排序的时候,如果将sum = num1 + num2 先于前两行代码执行,此时计算结果就会出错; 复制代码
当然,重排序的内容不是本文重点,有兴趣的读者自行百度。
synchronized可以保证在 同一时刻只有一个线程执行被synchronized修饰的方法/代码 ,即保证操作的原子性和可见性。
synchronized可以被用在三个地方:
下面我们通过代码来进行实践一下。
当没有明确给synchronized指明锁时, 默认获取到的是对象锁。
public synchronized void Method1(){ System.out.println("我是对象锁也是方法锁"); try{ Thread.sleep(500); } catch (InterruptedException e){ e.printStackTrace(); } } 复制代码
// 类锁:锁静态方法 public static synchronized void Method1(){ System.out.println("我是类锁"); try{ Thread.sleep(500); } catch (InterruptedException e){ e.printStackTrace(); } } 复制代码
// 类锁:锁静态代码块 public void Method2(){ synchronized (Test.class){ System.out.println("我是类锁"); try{ Thread.sleep(500); } catch (InterruptedException e){ e.printStackTrace(); } } } // 对象锁 public void Method(){ synchronized (this){ System.out.println("我是对象锁"); try{ Thread.sleep(500); } catch (InterruptedException e){ e.printStackTrace(); } } } } 复制代码
synchronized的工作流程是:
synchronized能够实现原子性和可见性,本质上依赖的是 底层操作系统的 互斥锁机制。
大家平时在写单例模式的时候,肯定知道用双重锁的方式,那么,为什么不用下面这种方式,这种方式存在什么缺点?
static synchroinzed SingleMan newInstance(){ if(sInstance = null){ sInstance= new SingleMan(); } } 复制代码
这个写法有什么坏处呢? 坏处是,把synchronized加上方法上时,作用的是整个对象的资源,当其他访问这个对象中的其他资源时,也需要等待。代价非常大。 举个例子:
public synchronized void setX(int x){ this.x = x; } public synchronized int getY(){ return this.y; } //当调用setX方法时,如果此时有其他线程想要调用getY方法,那么需要进行等待,因为此时锁已经被当前线程拿了。所以如果把synchroinzed加在方法上时,就算操作的不是相同的资源,也需要等待。代价比较大。 复制代码
那么,好的单例模式的写法是什么呢?答案是 不要使用对象锁,使用局部锁:
private static volatile SingleMan sInstance; //这里为什么要用volatile呢?因为有些对象在还没初始化完成的时候,对外就已经暴露不为空,但是此时还不能用,如果此时有线程使用了这个对象,就会有问题。加入volatile就可以同步状态 static SingleMan newInstance(){ if(sInstance = null){ //可能有两个线程同时到了这个地方,都觉得是空,然后可能会同时去尝试拿monitor,然后另外一个进入等待,当对象初始化后,等待的线程往下走,此时就已经不为空。所以,需要双重检查 synchroinzed(SingleMan.class){ if(sInstance = null){ sInstance= new SingleMan(); } } } } 复制代码
volatile关键字 只能用于修饰变量 ,无法用于修饰方法。并且volatile只能保证可见性,但不能保证操作的原子性。在具体编程中体现为: volatile只能保证基本类型以及一般对象的引用赋值是线程安全的 。举个例子:
volatile User user; private void setUserName(String userName){ user.name = userName;//不安全的 } private void setUser(User user){ this.user = user;//安全的,只能保证引用 } 复制代码
为什么volatile只能保证可见性,不能保证原子性呢? 这跟它的工作原理有关。
线程写volaitle变量的步骤为:
线程读volatile变量的步骤为:
由于在整个过程没有涉及到锁相关的操作,所以无法保证原子性,但是由于实时刷新了主内存中的变量值,因此任何时刻,不同线程总能看到该变量的最新值,保证了可见性。
下面出个练习来练练手: 有下面:point_down:这么一句代码:
private volatile int number =0; 复制代码
问:当创建500个线程同时操作number ++ 时,是否能保证最终打印的值是500?
答案:不能;因为number++不是原子操作,而volatile无法保证原子性。
那要如何改呢?
解法1:synchronized关键字 synchronized(this){ number++; } 解法2:使用ReentrankLock private ReentrankLock lock = new ReentrankLock(); lock.lock(); try{ number++; }finally{ lock.unlock(); } 解法3: 将int改成AtomicIntege 复制代码
要在多线程中安全的使用volatile变量,必须同时满足:
在实际项目中,由于很多情况下都不满意volatile的使用条件,所以volatile使用的场景并没有synchronized广。
在Java中,对64位(long、double)变量的读写可能不是原子操作,因为Java内存模型允许JVM将没有被volatile修饰的64位数据类型的读写操作划分为两次32位的读写操作来进行。 因此导致:有可能会出现读取到”半个变量“的情况; 解决方案是:加volatile关键字。
这里有同学可能会问啦,不是说volatile不保证原子性吗?为什么对于64位类型的变量用volatile修饰? 原因是:volatile本身不保证获取和设置操作的原子性,仅仅保持修改的可见性。但是java的内存模型保证声明为volatile的long和double变量的get和set操作是原子的。
请大家先思考以下问题: 对于一个公共变量,如果:
答案是:1、2、3会出问题,4不会出问题。1、2、3出问题的原因在于有一个线程对变量进行了修改,此时会导致数据发生改变,如果有另外一个线程要进行读取,会出现读取的数据可能出错。但是,当两个线程同时进行读操作的时候,是OK的,不会出现你读出来是个1,我读出来是个2的问题。
因为,当多个线程同时进行读操作的时候,我们就没有必要进行同步,浪费资源。为了减少这种资源浪费,读写锁就出现了~
读写锁维护了一对锁,一个读锁和一个写锁,同一时刻,可以有多个线程拿到读锁, 但是只有一个线程拿到写锁。 总结起来为:读读不互斥,读写互斥,写写互斥。
读写锁在Java中是 ReentrantReadWriteLock
,使用方式是:
import java.util.concurrent.locks.ReentrantReadWriteLock; public class ReadWriteLockDemo implements TestDemo { ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); ReentrantReadWriteLock.ReadLock readLock = lock.readLock(); ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock(); private int x = 0; private void count() { writeLock.lock(); try { x++; } finally { writeLock.unlock();// 保证当读的时候如果出现异常,会释放锁,synchronized为什么不用呢?因为synchronized内部已经帮我们做了~ } } private void print(int time) { readLock.lock(); try { for (int i = 0; i < time; i++) { System.out.print(x + " "); } System.out.println(); } finally { readLock.unlock();// 保证当读的时候如果出现异常,会释放锁,synchronized为什么不用呢?因为synchronized内部已经帮我们做了~ } } @Override public void runTest() { } } 复制代码
这个包里的类本身就被设计成原子的,可以方便我们实现线程安全。 比如:
int count ; //如果你想保证count++是安全的,但是不想用synchronized,那么使用AtomicInteger; 复制代码
好了,到这里本篇文章就已经结束了。在这次的文章中,我们主要简单介绍了线程和进程,详细了解了synchronized和volatile的工作原理,并对他们两者的使用场景进行了比较。相信你对多线程应该已经稍微熟悉一点了,现在来几道面试练练手,加深印象吧~Java线程面试题Top50
对于面试题里面的notify,wait,sleep等线程通信知识不了解的童鞋,欢迎期待下一场chat~