Lite om Swing och trådar

Sven-Olof Nyström
OOP med Java våren -25
Informationsteknologi
Uppsala Universitet

Om ni vill läsa om grundläggande programmering med trådar finns det gott om material, till exempel Skansholms kapitel 13 eller Oracle's tutorial https://docs.oracle.com/javase/tutorial/essential/concurrency/index.html.

Här säger jag några ord om varför användning av trådar kan medföra oväntade komplikationer. Sen beskriver jag hur Swing använder trådar.

Ni hittar alla programexempel här.

Trådar

Trådar i Java är användbara i två situationer. Trådar gör det möjligt att skriva program som är interaktiva samtidigt som de arbetar med nåt som tar tid. Det kan handla om ett program som genomför en större beräkning eller transporterar stora datamängder. Den andra situationen är när vi vill utnyttja moderna datorer som ofta har flera processorer.

I det första fallet handlar det om program som konceptuellt består av flera aktiviteter (concurrency). Ett populärt exempel när Java var nytt och internetuppkopplingar långsamma var Web-servrar. En web-server tar emot förfrågningar (om användaren klickar på en länk, tex) och levererar sidor. Om en användare har en långsam uppkoppling kan en nerladdning ta lång tid. Då vill du inte att nerladdningen ska blockera hela servern så att andra användare inte kan använda servern. Lösningen är att köra varje nerladdning i en egen tråd. Då kan servern fortsätta ta emot förfrågningar även om en nerladdning fortfarande håller på. På en dator med en processor växlar operativsystemet mellan de olika aktiviteterna. Konceptuellt sker aktivitererna oberoende av varandra men fysiskt kan endast en i taget köra.

Det andra fallet (parallellism) är när datorn du kör på tillåter att flera aktiviteter pågår samtidigt. Vi talar om datorer med flera processorer och flerkärniga processorer. (Det finns också processorer som stöder nåt som kallas multithreading—att processorn kan växla snabbt mellan olika aktiviteter.) Genom att dela upp en större beräkning i trådar och använda flera processorer kan du snabba upp beräkningen.

När man programmerar trådar i Java kan två trådar kommunicera genom att dela på en variabel så att när den ena ändrar variabeln ser den andra det. Man kan styra access med nyckelordet synchronized så att två trådar inte kan uppdatera ett objekt samtidigt. En annan situation man vill undvika är att en tråd besöker ett objekt när en annan tråd är halvvägs med en uppdatering av objektet.

Redan här finns fallgropar. Om man glömmer att synkronisera kan två trådar kollidera så att programmet inte gör vad det ska. Synkronisering låser objektet så att endast en tråd kommer åt, men med oförsiktig användning av synkronisering kan man få en situation där båda trådarna väntar på ett objekt som den andra tråden låst (deadlock). Men det blir värre.

Multiprocessorer och The Java Memory Model

Om du har tillgång till en multiprocessor (det har du säkert) måste du också ta hänsyn till nåt som kallas The Java Memory Model (JMM). För att göra multitrådade program effektivare får implementationen ge varje tråd ett eget minne. Det gör att det inte är säkert att en uppdatering av en variabel i en tråd är synlig i andra trådar (jag kommer att ge exempel på detta senare).

Vill du läsa mer om Java Memory Model föreslår jag du börjar med Wikipediaartikeln: https://en.wikipedia.org/wiki/Java_memory_model

(Notera att det är lite lurigt att googla "Java Memory Model". Jag gjorde det och ramlade på en massa artiklar om hur Java precis som de flesta andra programspråk lagrar data i stack och heap. Om du googlar, kom ihåg att skriva "The ..." eller ta med förkortningen "JMM".)

Eftersom kursen handlar om Java talar jag bara om Java, men problemet med att variabler som delas mellan trådar inte beter sig som man väntar sig är detsamma i C/C++ och i många andra programspråk med trådar. Du hittar mer information via wikipedialänken ovan.

Man kan fråga sig varför programspråket fungerar så här. Minnesmodellen är konstruerad för att göra livet enklare för den som implementerar programspråket men ställer högre krav på den som skriver programmet (du, alltså). Den motivering som brukar ges är att modern hårdvara kräver en minnesmodell av denna typ.

För många år sen ingick ett avsnitt om trådar i den här kursen. Jag tog upp ungefär de saker som står i kapitel 13 i Skansholms bok. Men det kändes inte som att studenterna fick lära sig nog för att skriva komplexa multitrådade program. Vi har dels problematiken med synkronisering och oförutsägbarhet som man får tampas med i all programmering med trådar, dels problematiken med Java Memory Model som gör att ett program som fungerar bra på en lite äldre dator kanske inte alls fungerar på en dator med fler processorer.

Swing

För att göra det möjligt att skriva program som kör stora beräkningar och samtidigt tillåter interaktion med användaren körs alla händelsemetoder i en separat tråd som vi kallar händelsetråden (event dispatch thread). Tråden som programmet kör vid start kallas huvudtråden (initial thread). Fler trådar kan skapas, men vi kommer inte att diskutera denna möjlighet.

Låt oss börja med ett enkelt exempel:

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;

public class ST1 extends JFrame implements ActionListener{
    public ST1 () {
        JPanel pane = new JPanel();
        JButton knapp1 = new JButton ("Blip");
        JButton knapp2 = new JButton ("Blup");
        knapp1.addActionListener(this);
        knapp2.addActionListener(this);
        pane.add(knapp1);
        pane.add(knapp2); // flytta
        add(pane);

        setBounds(100,100,300,200);
	setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        setVisible(true);
        // hit
    }

    public void actionPerformed(ActionEvent event) {
        System.out.println(((JButton)event.getSource()).getText());
    }

    public static void main(String[] arg) {
        new ST1();
    }
}

Programmet ritar två knappar med texten "Blip" och "Blup". När du klickar på en av knapparna skrivs motsvarande test ut. Kunde inte vara enklare, eller hur?

Vad händer när Swing har startat?

Låt oss nu se vad som händer om man flyttar kommandot pane.add(knapp2); till slutet av konstruktorn. Här är koden för konstruktorn:

(Program ST2.java).

    public ST2 () {
        JPanel pane = new JPanel();
        JButton knapp1 = new JButton ("Blip");
        JButton knapp2 = new JButton ("Blup");
        knapp1.addActionListener(this);
        knapp2.addActionListener(this);
        pane.add(knapp1);
        add(pane);

        setBounds(100,100,300,200);
	setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        setVisible(true);
        pane.add(knapp2);
    }

På min dator ritas bara den första knappen. Men när jag kör programmet en gång till ritas båda knapparna! Jag testar på min bärbara dator. Där visas inte den andra knappen. Så här vill man förstås inte ha det när man försöker få ett program att fungera!

Problemet är att konstruktorn evalueras i huvudtråden medan Swing evaluerar i händelsetråden. Det verkar som att händelsetråden vaknar till liv när huvudtråden utför metodanropet setVisible(true). Det som skett i huvudtråden före händelsetråden vaknar är tillgängligt för händelsetråden, men (ibland) inte det som sker efter.

I vilken tråd körs koden?

För att få hjälp med att reda ut i vilken tråd kod körs i definierar Swing en boolsk metod javax.swing.SwingUtilities.isEventDispatchThread().

Om metoden anropas i händelsetråden returnerar metoden true, annars false.

Jag skriver en enkel metod som anropar metoden ovan och skriver ut förklarande text (de exempel vi tittar på kommer endast att köra två trådar):

    public void vilkenTrad(String s) {
        if (javax.swing.SwingUtilities.isEventDispatchThread()) {
            System.out.println(s+" Handelsetrad");
        } else {
            System.out.println(s+" Huvudtrad");
        }
    }

Vi utgår från exempel ST1 och lägger till anrop till metoden vilkenTrad():

public class ST3 extends JFrame implements ActionListener{
    public ST3 () {
        JPanel pane = new JPanel();
        JButton knapp1 = new JButton ("Blip");
        JButton knapp2 = new JButton ("Blup");
        knapp1.addActionListener(this);
        knapp2.addActionListener(this);
        pane.add(knapp1);
        pane.add(knapp2);
        add(pane);

        setBounds(100,100,300,200);
	setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        vilkenTrad("Konstruktor:");
        setVisible(true);

    }

    public void actionPerformed(ActionEvent event) {
        System.out.println(((JButton)event.getSource()).getText());
        vilkenTrad("Action: ");
    }

    public void vilkenTrad(String s) {
        if (javax.swing.SwingUtilities.isEventDispatchThread()) {
            System.out.println(s+" Handelsetrad");
        } else {
            System.out.println(s+" Huvudtrad");
        }
    }

    public static void main(String[] arg) {
        ST3 frame = new ST3();
        frame.vilkenTrad("Main: ");

    }
}

Detta ger utskriften nedan. Utskrifterna med "Action" kommer när jag klickar på de två knapparna.

Konstruktor: Huvudtrad
Main:  Huvudtrad
Blip
Action:  Handelsetrad
Blup
Action:  Handelsetrad

Det är ungefär som man kunde vänta sig. Metoden "main" och anropet till konstruktorn körs in huvudtråden, anropen till actionPerformed() i händelsetråden.

Hur man skapar fönstret i händelsetråden

Så länge man inte har så mycket kommunikation mellan huvudtråden och händelsetråden brukar allt fungera. Numera rekommenderar Oracle dock att man ser till att även konstruktorn körs i händelsetråden.

Betrakta följande version av metoden main():

    public static void main(String[] arg) {
        SwingUtilities.invokeLater(new Runnable() {
                public void run() {
                    ST4 frame = new ST4();
                }
            });
    }

Vi har en anonym klass som implementerar gränssnittet Runnable. Klassen definierar en metod run() som anropar konstruktorn. Metoden SwingUtilities.invokeLater() tar ett objekt som implementerar gränssnittet Runnable och kör metoden run() i händelsetråden.

Vi utökar programexemplet med anrop till vilkenTrad. (Jag gjorde metoden static för att lättare kunna anropa den från main.)

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;

public class ST4 extends JFrame implements ActionListener{
    public ST4 () {
        JPanel pane = new JPanel();
        JButton knapp1 = new JButton ("Blip");
        JButton knapp2 = new JButton ("Blup");
        knapp1.addActionListener(this);
        knapp2.addActionListener(this);
        pane.add(knapp1);
        pane.add(knapp2);
        add(pane);

        setBounds(100,100,300,200);
	setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        vilkenTrad("Konstruktor:");
        setVisible(true);

    }

    public void actionPerformed(ActionEvent event) {
        System.out.println(((JButton)event.getSource()).getText());
        vilkenTrad("Action: ");
    }

    public static void vilkenTrad(String s) {
        if (javax.swing.SwingUtilities.isEventDispatchThread()) {
            System.out.println(s+" Handelsetrad");
        } else {
            System.out.println(s+" Huvudtrad");
        }
    }

    public static void main(String[] arg) {
        SwingUtilities.invokeLater(new Runnable() {
                public void run() {
                    ST4 frame = new ST4();
                    vilkenTrad("Run: ");
                }
            });
        vilkenTrad("Main: ");
    }
}

Som väntat är det endast koden som utförs direkt i "main" som körs av huvudtråden.

$ java ST4
Main:  Huvudtrad
Konstruktor: Handelsetrad
Run:  Handelsetrad
Blip
Action:  Handelsetrad
Blup
Action:  Handelsetrad
$

Utan och med synkronisering

Jag har också ett par exempel med trådar som försöker att interagera via en delad variabel, utan och med synkronisering (ST5.java och ST6.java). Här definierar klassen en instansvariabel x som huvudtråden försöker att komma åt. Program ST5.java saknar synkronisering så huvudtråden ser aldrig att x ändras. I program ST6.java är accessmetoderna synkroniserade så huvudtråden kan se när x får ett nytt värde.

Programmen ST5 och ST6 har utskrift från huvudloopen (Main) och händelsetråden (Frame). Om allt fungerar ska utskriften för ST6 se ut ungefär så här:

$ java ST6
Blip
Main: x is now 1
Frame: x is now 1
Blip
Frame: x is now 2
Main: x is now 2
Blup
Main: x is now 3
Frame: x is now 3
men när jag provkör ST5 ser huvudtråden aldrig att x ändras:
$ java ST5
Blip
Frame: x is now 1
Blip
Frame: x is now 2
Blup
Frame: x is now 3

Hur man undviker problem med Java memory model i Swing

I praktiken är problemen inte så stora som mina exempel kan ge intryck av.

De metoder som körs när du (till exempel) klickar på en knapp körs i Swings händelsetråd. Om du har kod i händelsetråden som ändrar på den bild som ska visas fungerar koden som du väntar dig. Både koden som ändrar bilden och koden sedan som ritar upp bilden (med metoden paintComponent) körs ju i händelsetråden.

Det är lite lurigare med konstruktorer som körs i huvudtråden. Om ditt program innehåller ett anrop av en Swing-konstruktor får du en osynkroniserad kommunikation mellan huvudtråden och händelsetråden. Detta sker (till exempel) i programmet ST1 ovan. På de maskiner jag har testat fungerar programmet ändå. Kanske beror det på att anropet setVisible(true) ligger sist i konstruktorn. I exemplet ST2 lägger jag till en knapp sist i konstruktorn, efter anropet till setVisible. När jag provkör på min bärbara dator visas aldrig den knappen. Den enkla strategin som verkar fungera bra är att alltid lägga anropet setVisible(true) sist i konstruktorn. En säkrare lösning visas i exemplet ST4.