在开发多线程程序时,如果每个多线程处理的事情都不一样,每个线程都互不相关,这样开发的过程就非常轻松。但是很多时候,多线程程序是需要同时访问同一个对象,或者变量的。这样,一个对象同时被多个线程访问,会出现处理的结果和预期不一致的可能。因此,需要了解如何对对象及变量并发访问,写出线程安全的程序,所谓线程安全就是处理的对象及变量的时候是同步处理的,在处理的时候其他线程是不会干扰。本文将从以下几个角度阐述这个问题。所有的代码都在char02
- 对于方法的同步处理
- 对于语句块的同步处理
- 对类加锁的同步处理
- 保证可见性的关键字——volatile
对于方法的同步处理
对于一个对象的方法,如果有两个线程同时访问,如果不加控制,访问的结果会出乎意料。所以我们需要对方法进行同步处理,让一个线程先访问,等访问结束,在让另一个线程去访问。对于要处理的方法,用synchronized
修饰该方法。我们下面看一下对比的例子。
首先是没有同步修饰的方法,看看会有什么意料之外的事情
public class HasSelfPrivateNum {
private int num = 0;
public void addI(String username){
try{
if (username.equals("a")){
num = 100;
System.out.println("a set over!");
Thread.sleep(2000);
}else {
num = 200;
System.out.println("b set over!");
}
System.out.println(username + " num=" + num);
}catch (Exception e){
e.printStackTrace();
}
}
}
public class SelfPrivateThreadA extends Thread{
private HasSelfPrivateNum num;
public SelfPrivateThreadA(HasSelfPrivateNum num){
this.num = num;
}
@Override
public void run() {
super.run();
num.addI("a");
}
}
public class SelfPrivateThreadB extends Thread{
private HasSelfPrivateNum num;
public SelfPrivateThreadB(HasSelfPrivateNum num){
this.num = num;
}
@Override
public void run() {
super.run();
num.addI("b");
}
}
测试的方法如下:
public class HasSelfPrivateNumTest extends TestCase {
public void testAddI() throws Exception {
HasSelfPrivateNum numA = new HasSelfPrivateNum();
// HasSelfPrivateNum numB = new HasSelfPrivateNum();
SelfPrivateThreadA threadA = new SelfPrivateThreadA(numA);
threadA.start();
SelfPrivateThreadB threadB = new SelfPrivateThreadB(numA);
threadB.start();
Thread.sleep(1000 * 3);
}
}
在这个对象中,有一个成员变量num, 如果username是a,则num应该等于100,如果是b,则num应该等于200,threadA与threadB同时去访问addI方法,预期的结果应该是a num=100 b num=200。但是实际的结果如下:
a set over!
b set over!
b num=200
a num=200
这是为什么呢?因为threadA先调用addI方法,但是因为传入的参数的是a,所示ThreadA线程休眠2s,这是B线程也已经调用了addI方法,然后将num的值改为了200,这是输出语句输出的是b改之后的num的值也就是200,a的值被b再次修改覆盖了。
这个方法是线程不安全的,我们给这个方法添加synchronized
,修改如下:
synchronized public void addI(String username){
try{
if (username.equals("a")){
num = 100;
System.out.println("a set over!");
Thread.sleep(2000);
}else {
num = 200;
System.out.println("b set over!");
}
System.out.println(username + " num=" + num);
}catch (Exception e){
e.printStackTrace();
}
}
其他地方保持不变,现在我们在看一下,结果:
a set over!
a num=100
b set over!
b num=200
这个结果是不是就符合预期的结果,调用的顺序也是一致的。
用synchronized
可以保证多线程调用同一个对象的方法的时候,是同步进行的,注意是同一个对象,也就是说synchronized
的方法是对象锁,锁住的是对象,如果是不同的对象,就没有这个线程不安全的问题。我们在上面的修改的基础上,去掉synchronized
,然后修改测试方法,让两个线程调用不同对象的方法,修改如下:
public class HasSelfPrivateNumTest extends TestCase {
public void testAddI() throws Exception {
HasSelfPrivateNum numA = new HasSelfPrivateNum();
HasSelfPrivateNum numB = new HasSelfPrivateNum();
SelfPrivateThreadA threadA = new SelfPrivateThreadA(numA);
threadA.start();
SelfPrivateThreadB threadB = new SelfPrivateThreadB(numA);
threadB.start();
Thread.sleep(1000 * 3);
}
}
结果如下:
b set over!
b num=200
a set over!
a num=100
因为threadB是不需要休眠的,所以两个线程同时调用的时候,一定是B线程先出结果,这个结果是符合预期的。但是这样是无法证明synchronized
是对象锁的,只能说明不同线程访问不同对象是不会出现线程不安全的情况的。在补充一个例子来证明:同一个对象,有两个同步方法,但是两个线程分别调用其中一个同步方法,如果返回的结果不是同时出现的,则说明是对象锁,即锁住了一个对象,该对象的其他方法也要等该对象锁释放,才能调用。
public class MyObject {
synchronized public void methodA(){
try{
System.out.println("begin methodA threadName=" + Thread.currentThread().getName()+
" begin time =" + System.currentTimeMillis());
Thread.sleep(5000);
System.out.println("end");
}catch (InterruptedException e){
e.printStackTrace();
}
}
synchronized public void methodB(){
try{
System.out.println("begin methodB threadName=" + Thread.currentThread().getName() +
" begin time =" + System.currentTimeMillis());
Thread.sleep(5000);
System.out.println("end");
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
public class SynchronizedMethodThread extends Thread{
private MyObject object;
public SynchronizedMethodThread(MyObject object){
this.object = object;
}
@Override
public void run() {
super.run();
if(Thread.currentThread().getName().equals("A")){
object.methodA();
}else{
object.methodB();
}
}
}
测试方法如下:
public class SynchronizedMethodThreadTest extends TestCase {
public void testRun() throws Exception {
MyObject object = new MyObject();
SynchronizedMethodThread a = new SynchronizedMethodThread(object);
a.setName("A");
SynchronizedMethodThread b = new SynchronizedMethodThread(object);
b.setName("B");
a.start();
b.start();
Thread.sleep(1000 * 15);
}
}
A,B两个线程分别调用methodA与methodB, 两个方法也打印出了他们的开始和结束时间。
结果如下:
begin methodA threadName=A begin time =1483603953885
end
begin methodB threadName=B begin time =1483603958886
end
可以看出两个方法是同步调用,一前一后,结果无交叉。说明synchronized
修饰方法添加的确实是对象锁。
这样,用synchronized
修饰的方法,都需要多线程同步调用,但是没用他修饰的方法,多线程还是直接去调用的。也就是说,虽然多线程会同步调用synchronized
修饰的方法,但是在一个线程同步调用方法的时候,其他线程可能先调用了非同步方法,这个在某些时候会有问题。比如出现脏读。
A线程先同步调用了set方法,但是可能在set的过程中出现了等待,然后其他线程在get的时候,数据是set还没有执行完的数据。看如下代码:
public class PublicVar {
public String username = "A";
public String password = "AA";
synchronized public void setValue(String username,String password){
try{
this.username = username;
Thread.sleep(3000);
this.password = password;
System.out.println("setValue method thread name=" + Thread.currentThread().getName() + " username="
+ username + " password=" + password);
}catch (InterruptedException e){
e.printStackTrace();
}
}
public void getValue(){
System.out.println("getValue method thread name=" + Thread.currentThread().getName() + " username=" + username
+ " password=" + password);
}
}
public class PublicVarThreadA extends Thread {
private PublicVar publicVar;
public PublicVarThreadA(PublicVar publicVar){
this.publicVar = publicVar;
}
@Override
public void run() {
super.run();
publicVar.setValue("B","BB");
}
}
看测试的例子:
public class PublicVarThreadATest extends TestCase {
public void testRun() throws Exception {
PublicVar publicVarRef = new PublicVar();
PublicVarThreadA threadA = new PublicVarThreadA(publicVarRef);
threadA.start();
Thread.sleep(40);
publicVarRef.getValue();
Thread.sleep(1000 * 5);
}
}
期待的结果应该是"A","AA",或者是"B","BB",然而结果是:
getValue method thread name=main username=B password=AA
setValue method thread name=Thread-0 username=B password=BB
所以,对于同一个对象中的数据读与取,都需要用synchronized
修饰才能同步。脏读一定会出现在操作对象情况下,多线程"争抢"对象的结果。
下面,说一些同步方法其他特性,当一个线程得到一个对象锁的时候,他再次请求对象锁,一定会再次得到该对象的锁。这往往出现在一个对象方法里调用这个对象的另一个方法,而这两个方法都是同步的。这样设计是有原因,因为如果不能再次获得这个对象锁的话,很容易造成死锁。这种直接获取锁的方式称之为可重入锁。
Java中的可重入锁支持在继承中使用,也就是说可以在子类的同步方法中调用父类的同步方法。
下面,看个例子:
public class FatherSynService {
public int i = 10;
synchronized public void operateIMainMethod(){
try{
i--;
System.out.println("main print i=" +i);
Thread.sleep(100);
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
public class SonSynService extends FatherSynService{
synchronized public void operateISubMethod(){
try{
while (i > 0){
i--;
System.out.println("sub print i=" + i);
Thread.sleep(1000);
this.operateIMainMethod();
}
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
public class SonSynTread extends Thread{
@Override
public void run() {
super.run();
SonSynService son = new SonSynService();
son.operateISubMethod();
}
}
测试的例子如下:
public class SonSynTreadTest extends TestCase {
public void testRun() throws Exception {
SonSynTread thread = new SonSynTread();
thread.start();
Thread.sleep(1000 * 10);
}
}
结果就是i是连续输出的。这说明,当存在父子类继承关系时,子类是完全可以通过"可重入锁"调用父类的同步方法的。但是在继承关系中,同步是不会被继承的,也就是说如果父类的方法是同步的方法,然而子类在覆写该方法的时候,没有加同步的修饰,则子类的方法不算是同步方法。
关于同步方法还有一点,就是同步方法出现未捕获的异常,则自动释放锁。
对于语句块的同步处理
对于上面的同步方法而言,其实是有些弊端的,如果同步方法是需要执行一个很长时间的任务,那么多线程在排队处理同步方法时就会等待很久,但是一个方法中,其实并不是所有的代码都需要同步处理的,只有可能会发生线程不安全的代码才需要同步。这时,可以采用synchronized
来修饰语句块让关键的代码进行同步。用synchronized
修饰同步块,其格式如下:
synchronized(对象){
//语句块
}
这里的对象,可以是当前类的对象this,也可以是任意的一个Object对象,或者间接继承自Object的对象,只要保证synchronized
修饰的对象被多线程访问的是同一个,而不是每次调用方法的时候都是新生成就就可以。但是特别注意String对象,因为JVM有String常量池的原因,所以相同内容的字符串实际上就是同一个对象,在用同步语句块的时候尽可能不用String。
下面,看一个例子来说明同步语句块的用法和与同步方法的区别:
public class LongTimeTask {
private String getData1;
private String getData2;
public void doLongTimeTask(){
try{
System.out.println("begin task");
Thread.sleep(3000);
String privateGetData1 = "长时间处理任务后从远程返回的值 1 threadName=" + Thread.currentThread().getName();
String privateGetData2 = "长时间处理任务后从远程返回的值 2 threadName=" + Thread.currentThread().getName();
synchronized (this){
getData1 = privateGetData1;
getData2 = privateGetData2;
}
System.out.println(getData1);
System.out.println(getData2);
System.out.println("end task");
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
public class LongTimeServiceThreadA extends Thread{
private LongTimeTask task;
public LongTimeServiceThreadA(LongTimeTask task){
super();
this.task = task;
}
@Override
public void run() {
super.run();
CommonUtils.beginTime1 = System.currentTimeMillis();
task.doLongTimeTask();
CommonUtils.endTime1 = System.currentTimeMillis();
}
}
public class LongTimeServiceThreadB extends Thread{
private LongTimeTask task;
public LongTimeServiceThreadB(LongTimeTask task){
super();
this.task = task;
}
@Override
public void run() {
super.run();
CommonUtils.beginTime2 = System.currentTimeMillis();
task.doLongTimeTask();
CommonUtils.endTime2 = System.currentTimeMillis();
}
}
测试的代码如下:
public class LongTimeServiceThreadATest extends TestCase {
public void testRun() throws Exception {
LongTimeTask task = new LongTimeTask();
LongTimeServiceThreadA threadA = new LongTimeServiceThreadA(task);
threadA.start();
LongTimeServiceThreadB threadB = new LongTimeServiceThreadB(task);
threadB.start();
try{
Thread.sleep(1000 * 10);
}catch (InterruptedException e){
e.printStackTrace();
}
long beginTime = CommonUtils.beginTime1;
if (CommonUtils.beginTime2 < CommonUtils.beginTime1){
beginTime = CommonUtils.beginTime2;
}
long endTime = CommonUtils.endTime1;
if (CommonUtils.endTime2 < CommonUtils.endTime1){
endTime = CommonUtils.endTime2;
}
System.out.println("耗时:" + ((endTime - beginTime) / 1000));
Thread.sleep(1000 * 20);
}
}
结果如下:
begin task
begin task
长时间处理任务后从远程返回的值 1 threadName=Thread-1
长时间处理任务后从远程返回的值 2 threadName=Thread-1
end task
长时间处理任务后从远程返回的值 1 threadName=Thread-1
长时间处理任务后从远程返回的值 2 threadName=Thread-1
end task
耗时:3
两个线程并发处理耗时任务只用了3s, 因为只在赋值的时候进行同步处理,同步语句块以外的部分都是多个线程异步处理的。
下面,说一下同步语句块的一些特性:
- 当多个线程同时执行
synchronized(x){}
同步代码块时呈同步效果。 - 当其他线程执行x对象中的
synchronized
同步方法时呈同步效果。 - 当其他线程执行x对象中的
synchronized(this)
代码块时也呈现同步效果。
细说一下每个特性,第一个特性上面的例子已经阐述了,就不多说了。第二个特性,因为同步语句块也是对象锁,所有当对x加锁的时候,x对象内的同步方法也呈现同步效果,当x为this的时候,该对象内的其他同步方法也要等待同步语句块执行完,才能执行。第三个特性和上面x为this是不一样的,第三个特性说的是,x对象中有一个方法,该方法中有一个synchronized(this)
的语句块的时候,也呈现同步效果。即A线程调用了对x加锁的同步语句块的方法,B线程在调用该x对象的synchronized(this)
代码块是有先后的同步关系。
上面说同步语句块比同步方法在某些方法中执行更有效率,同步语句块还有一个优点,就是如果两个方法都是同步方法,第一个方法无限在执行的时候,第二个方法就永远不会被执行。这时可以对两个方法做同步语句块的处理,设置不同的锁对象,则可以实现两个方法异步执行。
对类加锁的同步处理
和对象加锁的同步处理一致,对类加锁的方式也有两种,一种是synchronized
修饰静态方法,另一种是使用synchronized(X.class)
同步语句块。在执行上看,和对象锁一致都是同步执行的效果,但是和对象锁却有本质的不同,对对象加锁是访问同一个对象的时候成同步的状态,不同的对象就不会。但是对类加锁是用这个类的静态方法都是呈现同步状态。
下面,看这个例子:
public class StaticService {
synchronized public static void printA(){
try{
System.out.println(" 线程名称为:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 进入printA");
Thread.sleep(1000 * 3);
System.out.println(" 线程名称为:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 离开printA");
}catch (InterruptedException e){
e.printStackTrace();
}
}
synchronized public static void printB(){
System.out.println(" 线程名称为:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 进入printB");
System.out.println(" 线程名称为:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 离开printB");
}
synchronized public void printC(){
System.out.println(" 线程名称为:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 进入printC");
System.out.println(" 线程名称为:" + Thread.currentThread().getName()
+ " 在 " + System.currentTimeMillis() + " 离开printC");
}
}
测试方法如下:
public class StaticServiceTest extends TestCase {
public void testPrint() throws Exception{
new Thread(new Runnable() {
public void run() {
StaticService.printA();
}
}).start();
new Thread(new Runnable() {
public void run() {
StaticService.printB();
}
}).start();
new Thread(new Runnable() {
public void run() {
new StaticService().printC();
}
}).start();
Thread.sleep(1000 * 3);
}
}
结果如下:
线程名称为:Thread-0 在 1483630533783 进入printA
线程名称为:Thread-2 在 1483630533783 进入printC
线程名称为:Thread-2 在 1483630533783 离开printC
线程名称为:Thread-0 在 1483630536786 离开printA
线程名称为:Thread-1 在 1483630536787 进入printB
线程名称为:Thread-1 在 1483630536787 离开printB
很明显的看出来,对类加锁和对对象加锁两者方法是异步执行的,而对类加锁的两个方法是呈现同步执行。
其特性也和同步对象锁一样。
关于同步加锁的简单使用的介绍就到这里了。最后还有注意一点,锁对象锁的是该对象的内存地址,其存储的内容改变,并不会让多线程并发的时候认为这是不同的锁。所以改变锁对象的内容,并不会同步失效。
保证可见性的关键字——volatile
在多线程争抢对象的时候,处理该对象的变量的方式是在主内存中读取该变量的值到线程私有的内存中,然后对该变量做处理,处理后将值在写入到主内存中。上面举的例子,之所以出现结果与预期不一致都是因为线程自己将值复制到自己的私有栈后修改结果而不知道其他线程的修改结果。如果我们不用同步的话,我们就需要一个能保持可见的,知道其他线程修改结果的方法。JDK提供了volatile
关键字,来保持可见性,关键字volatile的作用是强制从公共堆栈中取得变量的值,而不是从线程私有数据栈中取得变量值。但是该关键字并不能保证原子性,以争抢一个对象中的count变量来看下图的具体说明:
java 垃圾回收整理一文中,描述了jvm运行时刻内存的分配。其中有一个内存区域是jvm虚拟机栈,每一个线程运行时都有一个线程栈,线程栈保存了线程运行时候变量值信息。当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存变量的具体值load到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。这样在堆中的对象的值就产生变化了。
volatile在此过程中的具体说明如下:
read and load 从主存复制变量到当前工作内存
use and assign 执行代码,改变共享变量值
store and write 用工作内存数据刷新主存相关内容
其中use and assign 可以多次出现
但是这一些操作并不是原子性,也就是 在read load之后,如果主内存count变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,所以计算出来的结果会和预期不一样对于volatile修饰的变量,jvm虚拟机只是保证从主内存加载到线程工作内存的值是最新的例如假如线程1,线程2 在进行read,load 操作中,发现主内存中count的值都是5,那么都会加载这个最新的值在线程1堆count进行修改之后,会write到主内存中,主内存中的count变量就会变为6线程2由于已经进行read,load操作,在进行运算之后,也会更新主内存count的变量值为6导致两个线程及时用volatile关键字修改之后,还是会存在并发的情况。
上述对于volatile的解析均摘自java中volatile关键字的含义
总结
至此,关于Java同步的知识就告一段落了,上文讲的都是比较粗浅的用法,我放在github的代码中有更多的例子,地址是:char02
关于多线程通信的知识就放在了char03的代码中。
Java多线程基础——对象及变量并发访问的更多相关文章
-
Java——多线程之对象及变量的并发访问
Java多线系列文章是Java多线程的详解介绍,对多线程还不熟悉的同学可以先去看一下我的这篇博客Java基础系列3:多线程超详细总结,这篇博客从宏观层面介绍了多线程的整体概况,接下来的几篇文章是对多线 ...
-
Java多线程编程核心技术,第二章,对象和变量并发访问
1,方法内部变量是线程安全的 2,实例变量非线程安全 3,synchronized是锁对象不是锁方法(锁对象是可以访问非synchronized方法,不可访问同个和其他synchronized方法 4 ...
-
[转]Java多线程干货系列—(一)Java多线程基础
Java多线程干货系列—(一)Java多线程基础 字数7618 阅读1875 评论21 喜欢86 前言 多线程并发编程是Java编程中重要的一块内容,也是面试重点覆盖区域,所以学好多线程并发编程对我们 ...
-
Java多线程--基础概念
Java多线程--基础概念 必须知道的几个概念 同步和异步 同步方法一旦开始,调用者必须等到方法调用返回后,才能执行后续行为:而异步方法调用,一旦开始,方法调用就立即返回,调用者不用等待就可以继续执行 ...
-
Java多线程基础知识总结
2016-07-18 15:40:51 Java 多线程基础 1. 线程和进程 1.1 进程的概念 进程是表示资源分配的基本单位,又是调度运行的基本单位.例如,用户运行自己的程序,系统就创建一个进程, ...
-
Java 多线程——基础知识
java 多线程 目录: Java 多线程——基础知识 Java 多线程 —— synchronized关键字 java 多线程——一个定时调度的例子 java 多线程——quartz 定时调度的例子 ...
-
Java 多线程基础(四)线程安全
Java 多线程基础(四)线程安全 在多线程环境下,如果有多个线程在同时运行,而这些线程可能会同时运行这段代码.程序每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线 ...
-
Java 多线程基础(五)线程同步
Java 多线程基础(五)线程同步 当我们使用多个线程访问同一资源的时候,且多个线程中对资源有写的操作,就容易出现线程安全问题. 要解决上述多线程并发访问一个资源的安全性问题,Java中提供了同步机制 ...
-
Java多线程基础:进程和线程之由来
转载: Java多线程基础:进程和线程之由来 在前面,已经介绍了Java的基础知识,现在我们来讨论一点稍微难一点的问题:Java并发编程.当然,Java并发编程涉及到很多方面的内容,不是一朝一夕就能够 ...
随机推荐
-
JAVA 1.2(原生数据类型 Primitive Data Type)
1. Java的数据类型分为2类 >> 原生数据类型(primitive data type) >> 引用数据类型(reference data type) 3. 常量和变量 ...
-
NOIP模拟赛-护花
[题目描述] 约翰留下他的N(N<=100000)只奶牛上山采木.他离开的时候,她们像往常一样悠闲地在草场里吃草.可是,当他回来的时候,他看到了一幕惨剧:牛们正躲在他的花园里,啃食着他心爱的美丽 ...
-
weblogic对jms实现的QueueConnection实现与TopicConnection实现问题
今天看了一段之前同事写jms的代码,觉得好像不对,但是不可能,生产上用的代码.刚开始想了下,脑子没转过弯来,后来一想是个简单的问题 代码如下: topicConnection = (TopicConn ...
-
oracle 修改用户下部分表现有表空间
工作日记之<修改用户表现有表空间> //user_tables可查询出当前登录用户的所有表,以及部分表信息,可以灵活运用于其他用途 //假设现有表空间TS1.TS2,需要迁移所有表空间TS ...
-
ubuntu下nvm,node以及npm的安装与使用
一:安装nvm 首先下载nvm,这里我们需要使用git,如果没有安装git,可以使用 sudo apt-get install git 来安装 git clone https://github.com ...
-
虚拟化平台cloudstack新版本的调试
虚拟化平台cloudstack(7)——新版本的调试 调试环境 ubuntu 12.04 JDK1.7 apache-maven-3.10 eclipse 4.2 Juno mysql 5 源码下载及 ...
-
tomcat基础应用
1. Tomcat版本和支持的API和JDK版本 Apache Tomcat Servlet API JSP API JDK 7.0 3.0 2.2 1.6 6.0 2.5 2.1 1.5 5.5 2 ...
-
Android 开发笔记___Intent的使用
public class ActRequestActivity extends AppCompatActivity implements OnClickListener { private EditT ...
-
【ODI】| 数据ETL:从零开始使用Oracle ODI完成数据集成(一)
0. 环境说明及软件准备 ODI(Oracle Data Integrator)是Oracle公司提供的一种数据集成工具,能高效地实现批量数据的抽取.转换和加载.ODI可以实现当今大多数的主流关系型数 ...
-
Ubuntu shutdown now 关机后 开机黑屏
一重装gdm3 失败 sudo apt-get remove --purge nvidia-* # 卸载nvidia相关组件 sudo apt purge gdm gdm3 # 卸载gdm和 ...